Install and release a Workspace add-on
Install the Drive checklist add-on for testing, rehearse a versioned release with a draft tester, and prepare the Marketplace listing and OAuth settings.
Identify which code and settings an installed user receives, test a harmless update and rollback, and record the IDs and settings a Marketplace release needs.
Release the Handoff checklist add-on from Build a personal Workspace add-on so other users can install it. The test installation runs your latest saved code. A release needs a versioned deployment, a standard Google Cloud project with OAuth settings, and a Google Workspace Marketplace listing.
Use the existing workspace-addons project. You can stop after testing the head deployment in your own Drive side panel. To test a versioned update and rollback, you need a second Google account as a draft tester. Before that test, link a standard Google Cloud project and configure the Marketplace SDK.
Identify the project, version, and deployment
An Apps Script project holds the editable source files and manifest. Its head is the most recently saved state of those files. A script version is an immutable snapshot of the files and manifest.
A versioned deployment has a stable Deployment ID and points at one numbered version. You can point the deployment at a different version without changing its ID or the Marketplace configuration. Saving edits in the Apps Script editor changes head. To release those edits, update the versioned deployment. See Create and manage deployments.
Record these six identifiers in your release notes:
| Item | Where to obtain it | What it identifies |
|---|---|---|
| Script ID | Apps Script Project Settings, under IDs | The script project |
| Script version number | Deploy > Manage deployments (selected deployment's Version) or Project History | An immutable snapshot in that script project |
| Deployment ID | Deploy > Manage deployments, under Deployment ID | A versioned deployment target that can be repointed to another version |
| Cloud project ID and project number | Google Cloud console dashboard, under Project info | The associated standard Google Cloud project (Apps Script's Change project dialog takes the numeric Project number) |
| OAuth client ID | Google Auth platform > Clients in the Cloud console | The OAuth client associated with the Cloud project |
| Marketplace listing URL and status | Marketplace SDK > Store Listing | The Marketplace distribution entry and its publication state |
Never put OAuth client secrets, authorization codes, access tokens, or refresh tokens in release notes or source files. Linking a standard Cloud project to Apps Script creates the Apps Script OAuth client automatically. You do not create a web-application OAuth client by hand or put a client secret in Code.gs. See Configure OAuth for Marketplace apps.
Install the development copy first
Use the same Google account in the editor and in Drive. Google's developer installation path requires edit access to the script project. Any additional developer testers must belong to the script owner's domain. Do not give end users script edit access as a way to distribute the add-on.
The manifest declares no OAuth scopes:
{
"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": {}
}
}
Open your Handoff checklist project in Apps Script and note its Script ID in Project Settings.
Choose Deploy > Test deployments > Install, then Done.
Refresh Drive in the same account and open Handoff checklist from the right-side panel.
Click Reload saved. If the account has no saved record yet, check the first two items, enter
REQ-001: check access with the next owner., and click Save checklist so the card shows2 of 3 checkedand revision1. If a saved record already exists, note its current revision number.
Predict, change, observe, restore
In buildChecklistCard_ in Code.gs from Build a personal Workspace add-on, change .setTitle('Handoff checklist') on the normal card header to .setTitle('Handoff checklist - B'). Leave the error card, manifest, callbacks, and property key unchanged.
Save Code.gs, refresh Drive, reopen the add-on, and click Reload saved. Because Test deployments runs head, the card header now reads Handoff checklist - B while the stored note, checked items, and revision number stay the same.
Restore .setTitle('Handoff checklist') in Code.gs, save, and reload the card in Drive to confirm the original title returns with the same saved revision. See Head and versioned deployments.
Establish release ownership before changing Cloud projects
Apps Script creates a default Google Cloud project automatically, and that is enough for Test deployments. Creating a versioned add-on deployment or publishing to the Google Workspace Marketplace requires a standard Google Cloud project. See Apps Script Cloud projects and Create and manage deployments.
Decide who holds each role before switching Cloud projects:
Script maintainers edit the source and manifest. The account that creates a versioned deployment owns that deployment. Transferring the script file does not transfer existing deployment ownership, and deleting the deployment owner's account can break active deployments.
Cloud administrators manage the standard Cloud project, billing, API enablement, and IAM roles. Linking Apps Script to an existing standard project requires the
Project BrowserandOAuth Config Editorroles.Marketplace publishers configure the Marketplace SDK with the
roles/appmetadata.workspaceMarketplaceAppConfigurationAdminrole. See Marketplace SDK troubleshooting.Brand and domain owners verify ownership of the public website domain and maintain the support and legal URLs.
Workspace administrators control domain installation policies and app allowlists.
Switching away from the default Cloud project deletes the default project permanently and requires existing users to reauthorize. Moving an already published Marketplace app to a different Cloud project does not carry over its Marketplace listing, users, or reviews. See Use a different standard project.
To link an existing standard Cloud project, copy its numeric Project number from the Cloud console, open Project Settings > Google Cloud Project > Change project in Apps Script, enter the project number, and click Set project.
In the standard Cloud project, open APIs & Services > Library and enable the Google Workspace Marketplace SDK. Private domain listings and public listings both use it. See Enable and configure the Marketplace SDK and Configure OAuth for Marketplace apps.
Configure audience, consent, and scopes separately
In the Cloud console, open Google Auth platform. Set the app name and emails under Branding, the audience under Audience, and scopes under Data Access. See Configure OAuth for Marketplace apps.
| Control | What the decision means |
|---|---|
| OAuth Internal | Only accounts in the Cloud project's Google Workspace organization can authorize. External accounts receive org_internal. |
| OAuth External | Google Accounts outside the organization can authorize, subject to the Testing or In production status. |
| OAuth Testing | Limits external authorization to up to 100 listed test users and expires refresh tokens after seven days. |
| OAuth In production | Removes the Testing user cap and seven-day expiration. Clicking Publish app under Audience changes only the OAuth status, not the Marketplace listing status. |
For an External app in Testing mode, add authorized accounts under Audience > Test users > Add users. A public Marketplace listing requires External and In production OAuth status. An organization-only listing can use Internal. See Manage OAuth app audience and Marketplace app review requirements.
Match the scope lists to the manifest
Open Data Access and verify that the declared OAuth scopes match both appsscript.json and Marketplace SDK > App Configuration > OAuth Scopes. For this checklist project, the scope list is empty. Remove any prefilled scopes in the SDK or OAuth console so all three places match. See Understand OAuth scopes.
When an add-on does request Google data scopes, Data Access classifies each scope as non-sensitive, sensitive, or restricted. A .readonly suffix does not by itself make a scope non-sensitive. A public app that requests sensitive scopes generally needs sensitive scope verification. Restricted scopes add permitted-use rules and may require a paid annual security assessment under restricted scope verification.
When a later release adds a scope, update appsscript.json, OAuth Data Access, the Marketplace SDK scope list, and the privacy policy. Complete any required OAuth verification before you release that version. See Update or unpublish a Marketplace listing.
Set the publisher name and privacy policy
Use the same app name and publisher identity in appsscript.json, OAuth Branding, and the Marketplace store listing. A public listing title cannot include a version number. You must also replace the sample's Google-hosted icon with your own artwork before submission. See Marketplace app review requirements.
Under Branding, enter your public app homepage, privacy policy URL, and terms of service URL. Add their domains to Authorized domains and verify ownership of each domain in Google Search Console. Google's Apps Script verification rules require the verified website owner to be an editor or owner of the script project. See OAuth brand verification, Verify site ownership, and Apps Script OAuth client verification.
Even with an empty oauthScopes, the privacy policy must describe accurately how the add-on handles the data users enter:
It stores the user's checklist note, checked item IDs, and revision number in Apps Script
UserPropertiesscoped to that user and script project.It does not read Drive files, inspect selections, change file permissions, or send checklist data to external servers.
Clearing the note and unchecking the boxes before clicking Save checklist overwrites the stored text with empty values but keeps the property and its revision number. Uninstalling the add-on does not erase
UserProperties.
See Marketplace program policies and Manage OAuth app branding.
Configure the correct Marketplace integration
Open APIs & Services > Google Workspace Marketplace SDK > App Configuration in the linked standard Cloud project. Google treats the visibility setting as permanent once saved, so decide between Private and Public before you save. A consumer Gmail account can create only Public listings. See Enable and configure the Marketplace SDK.
| Marketplace control | What it controls |
|---|---|
| Private | Only your Google Workspace organization can see the listing. |
| Public | Users outside your organization can find and install the listing after Marketplace review. |
| Unlisted | The listing is hidden from Marketplace search and browse results but can be installed from its direct URL. |
| Individual + Admin Install | Individual users and domain administrators can install the app, subject to domain policies. |
| Admin Only Install | Only Google Workspace administrators can install the app. |
| Store Listing Distribution regions | Which countries or regions can see the listing. |
Enable only the Google Workspace add-on integration:
| Apps Script add-on type | Marketplace SDK integration fields |
|---|---|
| Google Workspace add-on (this project) | Select Google Workspace add-on and enter the versioned Deployment ID from Deploy > Manage deployments. |
| Editor add-on (Docs, Sheets, Slides, Forms) | Uses the Script ID and a numeric Version, not a Deployment ID. |
Do not enable the SDK's separate Drive app integration for a CardService Drive side-panel add-on. See Choose where your app works. Enter your publisher contact details under Developer Information and click Save Draft.
Complete the listing, even for private distribution
Under Store Listing, fill in the language, application name, short and detailed descriptions, category, pricing, 32x32 and 128x128 icons, 220x140 card banner, and host screenshots. Add the Terms of service, Privacy policy, and Support URLs, then save the draft. See Create a Marketplace store listing.
Rehearse a numbered update without publishing it
With a standard Cloud project and a saved Marketplace listing draft, you can rehearse a versioned release. The publisher account manages Code.gs and deployments. A second account, the pilot, acts as a Marketplace draft tester. Do not install the editor's Test deployments copy in the pilot account. The pilot should receive only the versioned Marketplace draft.
Under Store Listing > Draft Tester, add the pilot's email address to Draft Tester Email Addresses and supply a working Draft Tester Opt-Out URL. The list holds up to 100 addresses: Gmail addresses for a public listing, or same-domain accounts for a private listing. If OAuth Testing mode is active, also add the pilot under Google Auth platform > Audience > Test users. See Test a draft store listing.
Establish baseline A
Confirm that
Code.gshas the original'Handoff checklist'title. In Deploy > New deployment, select Add-on, enter a rehearsal description, and click Deploy.In Manage deployments, record the created version number (version A) and Deployment ID (deployment R). See Apps Script versions.
Enter
R's Deployment ID in the Workspace add-on integration of the Marketplace SDK draft and click Save Draft. Do not click Publish or Submit for review.Signed in as the pilot account, open the draft listing preview URL and install the add-on. This requires Individual + Admin Install and domain permission.
In the pilot's Drive tab, open the add-on card, verify the title
Handoff checklist, and save or reload a checklist record to note the pilot's starting revision number.
Separate a source edit from a release update
In the publisher's Apps Script editor, change the card header title in Code.gs to 'Handoff checklist - B' and save. Refresh both accounts' Drive panels. The publisher's Test deployments installation shows Handoff checklist - B, because it runs head. The pilot's draft installation still shows Handoff checklist, because deployment R still points at immutable version A.
Next, advance R to a new version. Open Deploy > Manage deployments, select deployment R, click Edit, choose New version under Version, enter a description, and click Deploy. Record the new version number (version B). R's Deployment ID did not change, so the Marketplace SDK draft needs no edit. See Edit a versioned deployment.
Have the pilot refresh Drive, reopen the add-on, and click Reload saved. The pilot's card now shows Handoff checklist - B, and the pilot's saved note, checked items, and revision number are unchanged.
Roll back the deployment, then restore head
In Manage deployments, edit deployment R again, select version A from the Version dropdown instead of New version, and click Deploy. When the pilot refreshes Drive and clicks Reload saved, the card title returns to Handoff checklist with the pilot's saved checklist intact.
Finally, in the publisher's Apps Script editor, restore .setTitle('Handoff checklist') in Code.gs and save. You can compare any version against head in Project History with Highlight changes. See Apps Script versions.
| Stage | Developer head | R's selected version | Pilot's card title |
|---|---|---|---|
| Baseline | Handoff checklist | A | Handoff checklist |
Source saved with - B | Handoff checklist - B | A | Handoff checklist |
R advanced to version B | Handoff checklist - B | B | Handoff checklist - B |
R rolled back to version A | Handoff checklist - B | A | Handoff checklist |
| Head restored | Handoff checklist | A | Handoff checklist |
Rolling back a deployment switches the code snapshot but does not roll back UserProperties. Because the callback names, schema version, and item IDs stayed the same, version A can read a record saved under version B.
Prepare the review submission
A production release needs two separate submissions:
OAuth scope verification: if a later version of the add-on requests sensitive or restricted scopes, submit a rationale for each scope, the verified domain and branding links, and an unlisted YouTube video. The video must show the complete OAuth consent flow, including the app identity and
client_id, and each scope's feature in use. See Sensitive scope verification and Restricted scope verification.Marketplace review: provide complete listing copy, production icons and banners, step-by-step instructions for using the Drive card, and working URLs for support, the privacy policy, and the terms of service. See Marketplace app review requirements and Marketplace program policies.
In Store Listing, clicking Submit For Review publishes a Private domain listing immediately and sends a Public listing to Google's review queue. See Publish a Marketplace app and Update or unpublish a Marketplace listing. Track OAuth branding status in Branding and Verification Center, and listing status separately in Store Listing.
Find why the add-on is missing or blocked
| Observation | Inspect before changing anything |
|---|---|
| The developer add-on does not appear in Drive | Confirm that you are signed in to the same account, have script edit access, installed through Test deployments, kept addOns.drive in appsscript.json, and refreshed Drive. |
A user receives org_internal | Check OAuth Audience (Internal or External) and which organization owns the Cloud project. |
The SDK reports missing workspacemarketplace.appconfiguration.view or .update | Confirm the selected Cloud project and grant roles/appmetadata.workspaceMarketplaceAppConfigurationAdmin to the publisher account. |
| The pilot sees the published listing, or cannot install the draft | Check the pilot's signed-in email against Draft Tester Email Addresses, the draft preview URL, OAuth Test users (in Testing mode), and the domain installation policy. |
| The listing is reachable but cannot be installed | Check the SDK install mode (Admin Only or Individual + Admin Install), the regional distribution settings, and the Workspace Admin console allowlists. |
| A scope warning or verification block appears after an update | Compare appsscript.json, OAuth Data Access, and Marketplace SDK > App Configuration > OAuth Scopes. |
R changed but the card did not | Confirm which installation the pilot has, the Deployment ID in the SDK draft, and R's selected version, then refresh Drive and reopen the card. |
| A rolled-back release shows newer checklist data | Code versions do not revert UserProperties, so HANDOFF_CHECKLIST_V1 keeps the latest saved checklist. |
In a managed Google Workspace domain, administrators control Marketplace installation by organizational unit or group under Admin console > Apps > Google Workspace Marketplace apps > Apps list > User Install Settings. See Administrator controls for Marketplace installation.
Uninstall the test (stored data remains)
Confirm that
Code.gshas the original'Handoff checklist'title and record which versionRpoints at.In your developer account, choose Deploy > Test deployments > Uninstall > Done and refresh Drive. See Test and debug Apps Script Workspace add-ons.
Have the pilot uninstall the draft add-on from Drive or the Marketplace listing, and remove the pilot's address from Draft Tester Email Addresses if it is no longer needed.
When rehearsal deployment
Rhas no remaining testers, selectRin Deploy > Manage deployments and choose Archive deployment. See Create and manage deployments.
Unpublishing a live Marketplace listing (Store Listing > Unpublish) withdraws it for new installs. It does not uninstall the app from existing users or erase their UserProperties. See Update or unpublish a Marketplace listing.
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": {}
}
}