Google Forms scripts and response triggers
Build a Google Form in Apps Script with FormApp, add branching and quiz items, and handle form-submit trigger responses without duplicate work.
Configure a small form, distinguish its submit event from a Sheets event, and inspect known responses without recreating resources or repeating submissions blindly.
Create a workshop feedback form that asks about only the session the respondent attended, then gives everyone the same one-point practice question. An installable handler records each submitted response's identity and logs a small summary without sending email, creating a spreadsheet, or copying answers into another service.
This lesson builds on runtime and permissions and installing triggers safely. Use a new private standalone project. The form and its handler are separate resources: Forms stores a submission before the handler records that it saw it, so a failed handler does not mean the respondent should submit again.
Choose one owner and a private practice form
Use one approved test account for setup, trigger installation, inspection, and the optional responder exercise. The project creates a new form from scratch; keep both the script and the form private, and use the supplied fictional answers.
FormApp.create(title, false) creates an unpublished form. The second argument matters because the one-argument overload does not set that boundary. The script saves the returned form ID, configures the items, and leaves the form closed to responses without publishing it or granting access to anyone else. See FormApp.create.
Publishing controls whether responders can open a form; accepting responses controls whether an open form can receive submissions. Neither setting is an ownership check. The code checks supportsAdvancedResponderPermissions() before calling the current publication methods rather than falling back to a legacy access model. Because changing publication state can overwrite the accepting-responses setting, inspect both states after any change. See the Form reference.
Keep respondent identity separate from execution identity
The person answering a question is the respondent. The person who installs the trigger is the executing account for later handler runs. Even when both are the same test account, the roles are distinct: naming a function recordWorkshopSubmit does not give it the respondent's authority or make it a simple trigger.
This form collects no email addresses. Its alias question accepts fictional text such as Comet, which is not authentication. A form can require Google sign-in for access without recording the respondent's email address, and a free-text email field is not a verified account identity. Google's response settings distinguish Verified email collection from Responder input; FormResponse.getRespondentEmail() depends on the form's email-collection setting, not on a question title.
Keep Collect email addresses off and Limit to 1 response off so the same test account can submit both sample fixtures while keeping the form restricted. If your environment blocks form publication or trigger installation, you can still inspect the item structure, routing, and event handling in the source.
Install the complete project
Under the test account, open
script.google.com, choose New project, and name itWorkshop form practice. Keep the project private and standalone.Replace
Code.gswith the complete source below. In Project Settings, enable Show "appsscript.json" manifest file in editor, replaceappsscript.jsonwith the supplied manifest, and save both files.Keep
practice-responses.jsonas a local reference sheet for the manual responder exercise rather than adding it as a script file.Leave every string in
WORKSHOPempty andrecoveryItemIdasnull. Do not add a trigger through the UI or runrecordWorkshopSubmitmanually from the toolbar.Review the creation and recovery behavior below before authorizing setup. This project needs no Sheet, advanced service, library, or deployment.
/**
* @typedef {'alias'|'route'|'mapsPage'|'mapsNote'|'automationPage'|'automationNote'|'checkPage'|'check'} WorkshopItemKey
*/
/**
* @typedef {'TEXT'|'MULTIPLE_CHOICE'|'PAGE_BREAK'|'PARAGRAPH_TEXT'} WorkshopItemType
*/
/**
* @typedef {{key: WorkshopItemKey, type: WorkshopItemType, title: string}} WorkshopItemDefinition
*/
/**
* @typedef {{
* phase: string,
* formTitle: string,
* formId: string,
* itemIds: {[key: string]: number},
* pendingItem: string,
* triggerState: string,
* triggerId: string,
* responseIds: string[]
* }} WorkshopState
*/
const WORKSHOP = {
setupConfirmation: '', // CREATE ONE PRIVATE WORKSHOP FORM
installConfirmation: '', // INSTALL ONE WORKSHOP TRIGGER
recoveryFormId: '',
recoveryItemId: null,
recoveryConfirmation: '',
responseId: '',
removeTriggerConfirmation: '',
};
const WORKSHOP_STATE_KEY = 'forms-workflows.v1';
const WORKSHOP_HANDLER = 'recordWorkshopSubmit';
const MAX_WORKSHOP_RECEIPTS = 5;
/** @type {WorkshopItemDefinition[]} */
const WORKSHOP_ITEMS = [
{ key: 'alias', type: 'TEXT', title: 'Practice alias, not your real name' },
{ key: 'route', type: 'MULTIPLE_CHOICE', title: 'Which workshop did you try?' },
{ key: 'mapsPage', type: 'PAGE_BREAK', title: 'Maps lab feedback' },
{ key: 'mapsNote', type: 'PARAGRAPH_TEXT', title: 'One useful maps idea' },
{ key: 'automationPage', type: 'PAGE_BREAK', title: 'Automation lab feedback' },
{ key: 'automationNote', type: 'PARAGRAPH_TEXT', title: 'One useful automation idea' },
{ key: 'checkPage', type: 'PAGE_BREAK', title: 'A one-point practice check' },
{ key: 'check', type: 'MULTIPLE_CHOICE', title: 'What should happen before a script sends an email?' },
];
const MAPS_CHOICE = 'Maps lab';
const AUTOMATION_CHOICE = 'Automation lab';
const CORRECT_CHECK = 'Review the recipient and content';
const INCORRECT_CHECK = 'Keep retrying until something is sent';
function setupWorkshopForm() {
if (WORKSHOP.setupConfirmation !== 'CREATE ONE PRIVATE WORKSHOP FORM') {
throw new Error('Read the setup and effects, then supply the exact setup confirmation.');
}
withWorkshopLock_(() => {
let state = readWorkshopState_();
if (!state) {
state = {
phase: 'CREATE_UNCERTAIN',
formTitle: 'Book workshop practice [' + Utilities.getUuid() + ']',
formId: '',
itemIds: {},
pendingItem: '',
triggerState: 'NONE',
triggerId: '',
responseIds: [],
};
saveWorkshopState_(state); // Intent before creating any form.
const created = FormApp.create(state.formTitle, false); // Explicitly unpublished.
state.formId = created.getId();
state.phase = 'BUILDING';
saveWorkshopState_(state); // Identity before configuring or adding items.
}
if (state.phase === 'CREATE_UNCERTAIN') {
throw new Error('Form creation is uncertain. Inspect the state and confirm the existing identity.');
}
const form = openWorkshopForm_(state);
if (state.phase === 'READY') {
checkWorkshopItems_(form, state, 0);
console.log(JSON.stringify({ action: 'REUSED_READY_FORM', formId: state.formId }));
return;
}
requireUnpublishedWorkshop_(form);
if (state.pendingItem) {
throw new Error('An item append is uncertain: ' + state.pendingItem + '. Do not append again.');
}
checkWorkshopItems_(form, state, 0);
form.setAcceptingResponses(false);
form.setIsQuiz(true);
form.setCollectEmail(false);
form.setAllowResponseEdits(false);
form.setLimitOneResponsePerUser(false);
form.setPublishingSummary(false);
form.setShuffleQuestions(false);
form.setProgressBar(true);
form.setDescription('Fictional workshop practice only. No names, email addresses or live feedback.');
form.setConfirmationMessage('Your practice response is saved. No email is sent by this project.');
for (const definition of WORKSHOP_ITEMS) {
if (Object.prototype.hasOwnProperty.call(state.itemIds, definition.key)) continue;
state.pendingItem = definition.key;
saveWorkshopState_(state); // Each append has its own intent and ID checkpoint.
const item = appendWorkshopItem_(form, definition.type);
state.itemIds[definition.key] = item.getId();
state.pendingItem = '';
saveWorkshopState_(state); // Save before setting the item's title or options.
}
const items = checkWorkshopItems_(form, state, 0);
for (let index = 0; index < WORKSHOP_ITEMS.length; index++) {
items[index].setTitle(WORKSHOP_ITEMS[index].title);
}
configureWorkshopQuestions_(form, state);
state.phase = 'READY';
saveWorkshopState_(state);
console.log(JSON.stringify({ action: 'SETUP_READY', formId: state.formId, editUrl: form.getEditUrl(), items: 8 }));
});
}
function inspectWorkshopProject() {
const state = readWorkshopState_();
console.log(JSON.stringify({ savedState: state }));
if (!state || !state.formId) return;
const form = openWorkshopForm_(state);
const items = form.getItems(); // All items, not a paginated API. Use only this small new form.
if (items.length > WORKSHOP_ITEMS.length) throw new Error('More than eight items exist. Stop and inspect the form.');
console.log(JSON.stringify({
formId: state.formId,
editUrl: form.getEditUrl(),
published: form.isPublished(),
acceptingResponses: form.isAcceptingResponses(),
quiz: form.isQuiz(),
collectingEmail: form.collectsEmail(),
responseEditing: form.canEditResponse(),
items: items.map((/** @type {FormApp.Item} */ item) => ({ id: item.getId(), type: String(item.getType()), title: item.getTitle() })),
currentUserTriggers: ScriptApp.getProjectTriggers().map((trigger) => ({
id: trigger.getUniqueId(),
handler: trigger.getHandlerFunction(),
sourceId: trigger.getTriggerSourceId(),
eventType: String(trigger.getEventType()),
})),
}));
}
function adoptWorkshopForm() {
withWorkshopLock_(() => {
const state = requireWorkshopState_();
if (state.phase !== 'CREATE_UNCERTAIN') throw new Error('No unknown form identity is awaiting adoption.');
const id = WORKSHOP.recoveryFormId;
if (!isWorkshopId_(id) || WORKSHOP.recoveryConfirmation !== 'ADOPT FORM ' + id + ' FOR ' + state.formTitle) {
throw new Error('Inspect your new form and its ownership, then supply its ID and exact ADOPT FORM confirmation.');
}
const form = FormApp.openById(id);
requireUnpublishedWorkshop_(form);
if (form.getTitle() !== state.formTitle || form.getItems().length !== 0 || form.getDestinationId()) {
throw new Error('Candidate must be the inspected empty unpublished form from this attempt. Names alone are not proof.');
}
// The human confirmation establishes provenance/ownership; FormApp has no owner getter.
state.formId = form.getId();
state.phase = 'BUILDING';
saveWorkshopState_(state);
console.log(JSON.stringify({ action: 'FORM_ID_ADOPTED', formId: state.formId }));
});
}
function adoptWorkshopItem() {
withWorkshopLock_(() => {
const state = requireWorkshopState_();
if (state.phase !== 'BUILDING' || !state.pendingItem) throw new Error('No uncertain item append is awaiting adoption.');
const form = openWorkshopForm_(state);
requireUnpublishedWorkshop_(form);
const items = checkWorkshopItems_(form, state, 1);
const candidate = items[items.length - 1];
const definition = WORKSHOP_ITEMS[items.length - 1];
if (definition.key !== state.pendingItem || candidate.getId() !== WORKSHOP.recoveryItemId ||
WORKSHOP.recoveryConfirmation !== 'ADOPT ITEM ' + candidate.getId() + ' AS ' + state.pendingItem) {
throw new Error('Inspect the one unrecorded appended item and supply its exact ID and ADOPT ITEM confirmation.');
}
state.itemIds[state.pendingItem] = candidate.getId();
state.pendingItem = '';
saveWorkshopState_(state);
console.log(JSON.stringify({ action: 'ITEM_ID_ADOPTED', itemId: candidate.getId() }));
});
}
function installWorkshopTrigger() {
if (WORKSHOP.installConfirmation !== 'INSTALL ONE WORKSHOP TRIGGER') {
throw new Error('Inspect the ready form and supply the exact trigger-install confirmation.');
}
withWorkshopLock_(() => {
const state = requireReadyWorkshop_();
if (state.triggerState === 'REMOVED') throw new Error('This exercise trigger was retired. Do not reset its history.');
const form = openWorkshopForm_(state);
requireUnpublishedWorkshop_(form); // Prevent submission during installation/checkpointing.
checkWorkshopItems_(form, state, 0);
requireWorkshopResponseSettings_(form);
const triggers = ScriptApp.getProjectTriggers();
if (triggers.length > 1 || (triggers.length === 1 && !isWorkshopTrigger_(triggers[0], state))) {
throw new Error('This private project must contain only the one matching Forms trigger. Inspect; do not bulk-delete.');
}
if (triggers.length === 1) {
const id = triggers[0].getUniqueId();
if (state.triggerId && state.triggerId !== id) throw new Error('Saved and current trigger IDs differ. Stop and reconcile.');
if (state.triggerState !== 'READY') {
state.triggerId = id;
state.triggerState = 'READY';
saveWorkshopState_(state);
}
console.log(JSON.stringify({ action: 'REUSED_TRIGGER', triggerId: id }));
return;
}
if (state.triggerState !== 'NONE') throw new Error('Previously attempted trigger is missing. Do not blindly install another.');
state.triggerState = 'CREATE_UNCERTAIN';
saveWorkshopState_(state);
const trigger = ScriptApp.newTrigger(WORKSHOP_HANDLER).forForm(form).onFormSubmit().create();
state.triggerId = trigger.getUniqueId();
state.triggerState = 'READY';
saveWorkshopState_(state);
console.log(JSON.stringify({ action: 'TRIGGER_READY', triggerId: state.triggerId }));
});
}
/**
* @param {{response: FormApp.FormResponse, source: FormApp.Form, triggerUid: string|number}} e
* @returns {void}
*/
function recordWorkshopSubmit(e) {
if (!e || !e.response || typeof e.response.getItemResponses !== 'function' ||
!e.source || typeof e.source.getId !== 'function' || e.triggerUid === undefined) {
throw new Error('This handler needs the installed Forms submit event, not editor Run or a Sheets event.');
}
const responseId = e.response.getId();
if (typeof responseId !== 'string' || !isWorkshopId_(responseId)) {
throw new Error('No submitted response ID is available.');
}
// Preserve an ID for manual retrieval even if recording below fails. Never log the whole event.
console.log(JSON.stringify({ receivedResponseId: responseId }));
withWorkshopLock_(() => {
const state = requireReadyWorkshop_();
if (state.triggerState !== 'READY' || String(e.triggerUid) !== state.triggerId || e.source.getId() !== state.formId) {
throw new Error('Submission source or trigger does not match this user\'s saved project.');
}
requireWorkshopResponseSettings_(e.source);
rememberWorkshopResponse_(state, e.response);
});
}
function recordSelectedWorkshopResponse() {
if (!isWorkshopId_(WORKSHOP.responseId)) throw new Error('Set responseId to an inspected ID from this form\'s log or saved records.');
withWorkshopLock_(() => {
const state = requireReadyWorkshop_();
const form = openWorkshopForm_(state);
requireWorkshopResponseSettings_(form);
rememberWorkshopResponse_(state, form.getResponse(WORKSHOP.responseId));
});
}
function inspectWorkshopResponses() {
const state = requireReadyWorkshop_();
const form = openWorkshopForm_(state);
requireWorkshopResponseSettings_(form);
const ids = WORKSHOP.responseId ? [WORKSHOP.responseId] : state.responseIds;
if (ids.some((id) => !isWorkshopId_(id))) throw new Error('Supply a response ID, not an edit-response URL.');
for (const id of ids) {
const response = form.getResponse(id); // At most five direct lookups, never getResponses().
console.log(JSON.stringify(summarizeWorkshopResponse_(state, response)));
}
console.log(JSON.stringify({ responsesInspected: ids.length, recordedResponses: state.responseIds.length }));
}
function removeWorkshopTrigger() {
withWorkshopLock_(() => {
const state = requireReadyWorkshop_();
if (!state.triggerId || WORKSHOP.removeTriggerConfirmation !== 'REMOVE TRIGGER ' + state.triggerId) {
throw new Error('Supply the exact REMOVE TRIGGER confirmation for the saved ID.');
}
const trigger = ScriptApp.getProjectTriggers().find((candidate) => candidate.getUniqueId() === state.triggerId);
if (trigger) {
if (!isWorkshopTrigger_(trigger, state)) throw new Error('The identified trigger no longer matches. Stop.');
ScriptApp.deleteTrigger(trigger); // Only the explicitly confirmed trigger; never form data.
}
if (state.triggerState !== 'REMOVED') {
state.triggerState = 'REMOVED';
saveWorkshopState_(state);
}
console.log(JSON.stringify({ action: 'TRIGGER_REMOVED', triggerId: state.triggerId }));
});
}
/**
* @param {FormApp.Form} form
* @param {WorkshopState} state
* @returns {void}
*/
function configureWorkshopQuestions_(form, state) {
const alias = requireWorkshopItem_(form, state.itemIds.alias).asTextItem();
alias.setRequired(true).setPoints(0).setHelpText('Use Comet or Cedar. This is not identity verification.');
requireWorkshopItem_(form, state.itemIds.mapsNote).asParagraphTextItem()
.setRequired(true).setPoints(0).setHelpText('One short fictional sentence. No personal information.');
requireWorkshopItem_(form, state.itemIds.automationNote).asParagraphTextItem()
.setRequired(true).setPoints(0).setHelpText('One short fictional sentence. No personal information.');
const mapsPage = requireWorkshopItem_(form, state.itemIds.mapsPage).asPageBreakItem();
const automationPage = requireWorkshopItem_(form, state.itemIds.automationPage).asPageBreakItem();
const checkPage = requireWorkshopItem_(form, state.itemIds.checkPage).asPageBreakItem();
const route = requireWorkshopItem_(form, state.itemIds.route).asMultipleChoiceItem();
route.setRequired(true).setPoints(0).showOtherOption(false).setChoices([
route.createChoice(MAPS_CHOICE, mapsPage),
route.createChoice(AUTOMATION_CHOICE, automationPage),
]);
mapsPage.setGoToPage(FormApp.PageNavigationType.CONTINUE);
// This break controls the END of the preceding Maps page, not the Automation page.
automationPage.setGoToPage(checkPage);
checkPage.setGoToPage(FormApp.PageNavigationType.CONTINUE);
const check = requireWorkshopItem_(form, state.itemIds.check).asMultipleChoiceItem();
check.setRequired(true).setPoints(1).showOtherOption(false).setChoices([
check.createChoice(CORRECT_CHECK, true),
check.createChoice(INCORRECT_CHECK, false),
]);
check.setFeedbackForCorrect(FormApp.createFeedback().setText('Correct. Review the actual recipient and content before sending.').build());
check.setFeedbackForIncorrect(FormApp.createFeedback().setText('An uncertain send must be inspected, not blindly repeated.').build());
}
/**
* @param {FormApp.Form} form
* @param {number} id
* @returns {FormApp.Item}
*/
function requireWorkshopItem_(form, id) {
const item = form.getItemById(id);
if (item === null) throw new Error('A recorded item is missing. Stop and inspect the form.');
return item;
}
/**
* @param {FormApp.Form} form
* @param {WorkshopItemType} type
* @returns {FormApp.Item}
*/
function appendWorkshopItem_(form, type) {
switch (type) {
case 'TEXT': return form.addTextItem();
case 'MULTIPLE_CHOICE': return form.addMultipleChoiceItem();
case 'PAGE_BREAK': return form.addPageBreakItem();
case 'PARAGRAPH_TEXT': return form.addParagraphTextItem();
default: throw new Error('Unsupported workshop item type.');
}
}
/**
* @param {FormApp.Form} form
* @param {WorkshopState} state
* @param {number} extra
* @returns {FormApp.Item[]}
*/
function checkWorkshopItems_(form, state, extra) {
const items = form.getItems();
const knownCount = Object.keys(state.itemIds).length;
if (items.length !== knownCount + extra || items.length > WORKSHOP_ITEMS.length) {
throw new Error('Unexpected item count. Inspect saved and actual IDs; never append to compensate.');
}
for (let index = 0; index < items.length; index += 1) {
const definition = WORKSHOP_ITEMS[index];
if (items[index].getType() !== workshopItemType_(definition.type) ||
(index < knownCount && items[index].getId() !== state.itemIds[definition.key])) {
throw new Error('Item order, type or saved ID changed at ' + definition.key + '. Stop.');
}
}
return items;
}
/**
* @param {WorkshopItemType} type
* @returns {FormApp.ItemType}
*/
function workshopItemType_(type) {
switch (type) {
case 'TEXT': return FormApp.ItemType.TEXT;
case 'MULTIPLE_CHOICE': return FormApp.ItemType.MULTIPLE_CHOICE;
case 'PAGE_BREAK': return FormApp.ItemType.PAGE_BREAK;
case 'PARAGRAPH_TEXT': return FormApp.ItemType.PARAGRAPH_TEXT;
}
}
/**
* @param {WorkshopState} state
* @returns {FormApp.Form}
*/
function openWorkshopForm_(state) {
if (!state.formId) throw new Error('No confirmed form ID is saved. Reconcile creation first.');
const form = FormApp.openById(state.formId);
if (!form.supportsAdvancedResponderPermissions() || form.getTitle() !== state.formTitle || form.getDestinationId()) {
throw new Error('The saved form has unsupported publishing, a changed title or a linked destination. Inspect it.');
}
return form;
}
/**
* @param {FormApp.Form} form
* @returns {void}
*/
function requireUnpublishedWorkshop_(form) {
if (!form.supportsAdvancedResponderPermissions() || form.isPublished()) {
throw new Error('Setup requires the original unpublished practice form. No legacy publication fallback is provided.');
}
}
/**
* @param {FormApp.Form} form
* @returns {void}
*/
function requireWorkshopResponseSettings_(form) {
if (!form.isQuiz() || form.collectsEmail() || form.canEditResponse() || form.isPublishingSummary() || form.getDestinationId()) {
throw new Error('Keep quiz mode on, email collection/response editing/results summary off, and no linked Sheet.');
}
}
/**
* @param {ScriptApp.Trigger} trigger
* @param {WorkshopState} state
* @returns {boolean}
*/
function isWorkshopTrigger_(trigger, state) {
return trigger.getHandlerFunction() === WORKSHOP_HANDLER &&
trigger.getTriggerSource() === ScriptApp.TriggerSource.FORMS &&
trigger.getEventType() === ScriptApp.EventType.ON_FORM_SUBMIT &&
trigger.getTriggerSourceId() === state.formId;
}
/**
* @param {WorkshopState} state
* @param {FormApp.FormResponse} response
* @returns {{responseId: string, submittedAt: string, branch: string, feedbackCharacters: number, practiceAnswerMatchesKey: boolean, observedQuizScore: number|string}}
*/
function summarizeWorkshopResponse_(state, response) {
const id = response.getId();
const timestamp = response.getTimestamp();
if (typeof id !== 'string' || !isWorkshopId_(id) || !timestamp || !Number.isFinite(timestamp.getTime())) {
throw new Error('A submitted response with its own ID and timestamp is required.');
}
/** @type {Map<string, {value: string, itemResponse: FormApp.ItemResponse}>} */
const answers = new Map();
const itemResponses = response.getItemResponses();
if (itemResponses.length > 5) throw new Error('Unexpected response structure; this project has five questions.');
for (const itemResponse of itemResponses) {
const item = itemResponse.getItem();
const definition = WORKSHOP_ITEMS.find((candidate) => state.itemIds[candidate.key] === item.getId());
if (!definition || definition.type === 'PAGE_BREAK' || item.getType() !== FormApp.ItemType[definition.type] || answers.has(definition.key)) {
throw new Error('Unknown, repeated or changed question identity in the response.');
}
const value = itemResponse.getResponse();
if (typeof value !== 'string') throw new Error('These questions return strings; do not flatten a checkbox or grid response.');
answers.set(definition.key, { value: value, itemResponse: itemResponse });
}
const text = (/** @type {string} */ key) => {
const answer = answers.get(key);
return answer ? answer.value : '';
};
const branch = text('route');
if (!text('alias').trim() || ![MAPS_CHOICE, AUTOMATION_CHOICE].includes(branch) ||
![CORRECT_CHECK, INCORRECT_CHECK].includes(text('check'))) {
throw new Error('Required practice answers are absent or outside the supplied choices.');
}
const noteKey = branch === MAPS_CHOICE ? 'mapsNote' : 'automationNote';
const otherKey = branch === MAPS_CHOICE ? 'automationNote' : 'mapsNote';
if (!text(noteKey).trim() || text(otherKey).trim()) throw new Error('Response does not match one completed feedback branch.');
const checkAnswer = answers.get('check');
if (!checkAnswer) throw new Error('The practice check answer is absent.');
const rawScore = checkAnswer.itemResponse.getScore();
return {
responseId: id,
submittedAt: timestamp.toISOString(),
branch: branch,
feedbackCharacters: text(noteKey).length,
practiceAnswerMatchesKey: text('check') === CORRECT_CHECK,
observedQuizScore: typeof rawScore === 'number' && Number.isFinite(rawScore) ? rawScore : 'not_available',
};
}
/**
* @param {WorkshopState} state
* @param {FormApp.FormResponse} response
* @returns {void}
*/
function rememberWorkshopResponse_(state, response) {
const summary = summarizeWorkshopResponse_(state, response);
if (state.responseIds.includes(summary.responseId)) {
console.log(JSON.stringify({ action: 'ALREADY_RECORDED', responseId: summary.responseId }));
return;
}
if (state.responseIds.length >= MAX_WORKSHOP_RECEIPTS) {
throw new Error('Five response IDs are already recorded. The submitted response remains in Forms; stop further practice submissions.');
}
state.responseIds.push(summary.responseId);
saveWorkshopState_(state);
console.log(JSON.stringify({ action: 'RESPONSE_RECORDED', summary: summary }));
}
function requireWorkshopState_() {
const state = readWorkshopState_();
if (!state) throw new Error('Run the deliberate setup first. No saved project exists for this user.');
return state;
}
function requireReadyWorkshop_() {
const state = requireWorkshopState_();
if (state.phase !== 'READY') throw new Error('Finish or reconcile setup before working with triggers or responses.');
return state;
}
function readWorkshopState_() {
const raw = PropertiesService.getUserProperties().getProperty(WORKSHOP_STATE_KEY);
if (raw === null) return null;
/** @type {WorkshopState} */
let state;
try { state = JSON.parse(raw); }
catch (error) { throw new Error('Saved state is invalid JSON. Preserve it; do not reset it.'); }
const invalid = () => { throw new Error('Saved state is invalid. Preserve the form, item, trigger and response IDs.'); };
if (!state || !['CREATE_UNCERTAIN', 'BUILDING', 'READY'].includes(state.phase) ||
typeof state.formTitle !== 'string' || !/^Book workshop practice \[[0-9a-f-]{36}\]$/i.test(state.formTitle) ||
typeof state.formId !== 'string' || !state.itemIds || typeof state.itemIds !== 'object' || Array.isArray(state.itemIds) ||
typeof state.pendingItem !== 'string' || !['NONE', 'CREATE_UNCERTAIN', 'READY', 'REMOVED'].includes(state.triggerState) ||
typeof state.triggerId !== 'string' || !Array.isArray(state.responseIds) || state.responseIds.length > MAX_WORKSHOP_RECEIPTS) invalid();
const count = Object.keys(state.itemIds).length;
if (count > WORKSHOP_ITEMS.length || WORKSHOP_ITEMS.slice(0, count).some((item) => !Number.isInteger(state.itemIds[item.key])) ||
new Set(Object.values(state.itemIds)).size !== count ||
(state.pendingItem && (count >= WORKSHOP_ITEMS.length || state.pendingItem !== WORKSHOP_ITEMS[count].key)) ||
state.responseIds.some((id) => !isWorkshopId_(id)) || new Set(state.responseIds).size !== state.responseIds.length) invalid();
if (state.phase === 'CREATE_UNCERTAIN' ? state.formId !== '' || count !== 0 || state.pendingItem !== '' : !isWorkshopId_(state.formId)) invalid();
if (state.phase === 'READY' && (count !== WORKSHOP_ITEMS.length || state.pendingItem)) invalid();
if (['READY', 'REMOVED'].includes(state.triggerState) ? !isWorkshopId_(state.triggerId) : state.triggerId !== '') invalid();
if (state.phase !== 'READY' && (state.triggerState !== 'NONE' || state.responseIds.length)) invalid();
return state;
}
/**
* @param {WorkshopState} state
* @returns {void}
*/
function saveWorkshopState_(state) {
const text = JSON.stringify(state);
if (Utilities.newBlob(text).getBytes().length > 8000) throw new Error('Saved state exceeds the practice allowance. Do not erase history.');
PropertiesService.getUserProperties().setProperty(WORKSHOP_STATE_KEY, text);
}
/**
* @param {function(): void} operation
* @returns {void}
*/
function withWorkshopLock_(operation) {
const lock = LockService.getUserLock();
if (!lock.tryLock(1000)) throw new Error('Another execution holds this user lock. Inspect its result before repeating work.');
try { operation(); }
finally { lock.releaseLock(); }
}
/**
* @param {string} value
* @returns {boolean}
*/
function isWorkshopId_(value) {
return typeof value === 'string' && value.length > 0 && value.length <= 1024 && !/\s/.test(value);
}
{
"timeZone": "Etc/UTC",
"dependencies": {},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"oauthScopes": [
"https://www.googleapis.com/auth/forms",
"https://www.googleapis.com/auth/script.scriptapp"
]
}
The forms scope authorizes creating and managing Forms, including settings, questions, and responses. It is broader than this single practice form, and forms.currentonly cannot replace it for standalone FormApp.create and openById calls. The script.scriptapp scope authorizes managing the executing user's project triggers. The FormApp reference and ScriptApp reference list these method requirements.
Review both permissions under the intended owner account. If the consent screen offers granular selection, grant both permissions so setup and trigger installation can run again without duplicating questions or triggers. Because an unattended trigger cannot prompt for missing consent, resolve any permission issues manually under the same account and inspect saved state before continuing. See authorization scopes. The project requests no Gmail, Drive, Sheets, or external HTTP scopes.
Create once, then inspect the actual structure
Set WORKSHOP.setupConfirmation to CREATE ONE PRIVATE WORKSHOP FORM, save, select setupWorkshopForm, and click Run. Authorize the prompt if you approve creating the practice form, then clear setupConfirmation after the run finishes.
A successful setup logs SETUP_READY, the generated formId, its editUrl, and items: 8. Run inspectWorkshopProject next. Expect saved phase: "READY", eight item IDs, triggerState: "NONE", and an empty responseIds array. The form inspection reports published: false, acceptingResponses: false, quiz: true, collectingEmail: false, and responseEditing: false.
Open the logged editUrl and confirm that you own the form and that it remains unshared. Inspect the eight items in order:
| Source key | Item type | Reader-facing purpose |
|---|---|---|
alias | TEXT | Required fictional alias |
route | MULTIPLE_CHOICE | Required choice of Maps lab or Automation lab |
mapsPage | PAGE_BREAK | Starts Maps lab feedback |
mapsNote | PARAGRAPH_TEXT | Required maps feedback on that branch |
automationPage | PAGE_BREAK | Starts Automation lab feedback |
automationNote | PARAGRAPH_TEXT | Required automation feedback on that branch |
checkPage | PAGE_BREAK | Starts the shared practice check |
check | MULTIPLE_CHOICE | Required one-point question about reviewing an email before sending |
The form has five questions and three page breaks, producing four pages. A page break is an Item in form.getItems() even though it is not a question. The script maps its own stable keys to the integer item IDs assigned by Forms, since neither an item's array index nor its title is a durable identifier.
A generic Item exposes shared methods such as getId, getType, and setTitle. To configure choices, the script verifies that the item type is MULTIPLE_CHOICE and calls asMultipleChoiceItem(), which throws if called on another item type. See the Item contract. Recording each item's ID, type, and order lets setup reject a manually replaced or reordered item instead of appending a duplicate.
Check Settings in the form editor alongside the execution log. Keep quiz mode on, email collection and response editing off, question shuffling off, and View results summary off. In Responses, leave the form unlinked from Sheets.
To verify idempotency, set setupConfirmation again, run setupWorkshopForm under the same account, and clear the confirmation afterward. Expect REUSED_READY_FORM with the same formId, eight items, and no second form. Once phase is READY, setup returns the existing form without overwriting manual edits, so leave the questions, choices, answer key, and routing intact.
Route each respondent to one feedback page
Multiple-choice options can carry page destinations as well as labels. Where setChoiceValues(['Maps lab', 'Automation lab']) sets plain labels, this project calls route.createChoice(label, pageBreakItem) for each branch and passes both Choice objects to setChoices.
Choosing Maps lab sends the respondent to mapsPage; choosing Automation lab sends them to automationPage when they click Next at the end of the first page. Both choices carry navigation, and Other is disabled; the MultipleChoiceItem contract forbids mixing navigation and non-navigation choices in the same item.
After the respondent finishes the Maps feedback page, the form must skip the Automation feedback page. In the Forms API, automationPage.setGoToPage(checkPage) controls where the form goes at the end of the preceding Maps page when normal progression reaches automationPage, not what happens after the Automation page. Similarly, checkPage.setGoToPage(FormApp.PageNavigationType.CONTINUE) lets the preceding Automation page continue into the shared check. Choice-level navigation on page one overrides page-break navigation at mapsPage. See PageBreakItem.setGoToPage.
That configuration produces two paths:
Alias and route, then Maps feedback, then the shared check, then submit.
Alias and route, then Automation feedback, then the shared check, then submit.
Each path shows only the selected lab's required feedback question. Because page navigation is presentation logic rather than a server-side constraint on the payload, the submit handler independently checks the chosen branch, requires its matching note, and rejects a nonempty note for the other branch. Start each test submission in a fresh responder session rather than clicking Back after typing a note.
Keep routing and grading separate
form.setIsQuiz(true) enables quiz grading. The alias, route, and feedback items carry zero points, while check carries one point. The choice Review the recipient and content is marked correct and Keep retrying until something is sent is marked incorrect, with distinct answer feedback built via FormApp.createFeedback().setText(...).build().
Notice that check calls createChoice(label, true), the correct-answer overload, whereas route calls createChoice(label, pageBreakItem), the page-navigation overload. Because MultipleChoiceItem has no overload that combines a destination page and a correctness boolean on the same choice, the form keeps routing and grading in separate questions. Multiple-choice quiz items are autograded (see the MultipleChoiceItem reference); assigning points to a text item does not create an automatic answer key.
Do not toggle the ready form out of quiz mode: calling setIsQuiz(false) strips existing grading configuration from its items (see Form.setIsQuiz), and rerunning setupWorkshopForm in READY state will not restore it.
The handler summary logs two fields for the quiz item:
practiceAnswerMatchesKeycompares the submitted answer string against the expected constant in code.observedQuizScorereports a finite number fromItemResponse.getScore(), ornot_availablewhengetScore()does not return a number at handler time.not_availableis distinct from0.
Once Forms grades the unmodified check item, the correct choice scores 1 and the incorrect choice scores 0. If observedQuizScore is not_available during the submit trigger, inspect the response later with inspectWorkshopResponses rather than submitting a duplicate response. In Settings > Release grades and Respondent settings, keep Immediately after each submission with email collection off so grade release never sends email. See Google's quiz instructions.
The handler only reads scores; it never calls setScore, withItemGrade, or submitGrades. Calling setScore on an ItemResponse does not persist until submitted through Form.submitGrades(), and installable-trigger restrictions warn that Form.submitGrades() can re-trigger a form-submit handler.
Install the Forms trigger under the same owner
With the ready form still unpublished, set WORKSHOP.installConfirmation to INSTALL ONE WORKSHOP TRIGGER, save, and run installWorkshopTrigger. Clear the confirmation afterward. Expect TRIGGER_READY and a saved trigger ID, then verify the state with inspectWorkshopProject and the editor's Triggers page.
The installer runs ScriptApp.newTrigger('recordWorkshopSubmit').forForm(form).onFormSubmit().create(). That builder attaches recordWorkshopSubmit to the saved Form (not a destination Sheet) for the ON_FORM_SUBMIT event; see the FormTriggerBuilder reference. Do not add a second trigger in the UI or rename the handler to onFormSubmit.
Running installWorkshopTrigger a second time while the form is unpublished logs REUSED_TRIGGER with the same trigger ID. The installer checks the handler function name, FORMS trigger source, ON_FORM_SUBMIT event type, and source form ID, and stops if it sees unrelated or duplicate project triggers. Because ScriptApp.getProjectTriggers() returns only the current user's installable triggers in this project, keep all setup and trigger management under the single owner account. See ScriptApp.getProjectTriggers and the Trigger reference.
Read the event that Forms actually supplies
Installable form-submit triggers have two distinct event shapes depending on the trigger resource:
| Trigger source | Submission data | Identity and context |
|---|---|---|
Google Forms (forForm) | e.response is a FormResponse; call getItemResponses() for its answers | e.source is the Form, e.triggerUid identifies the installed trigger, and e.authMode is FULL |
Google Sheets (forSpreadsheet) | e.namedValues maps question titles to string arrays; e.values follows sheet column order | e.range locates the appended row alongside e.triggerUid and e.authMode; there is no e.response |
See Google Forms events. A Forms submit event does not populate e.user. While e.triggerUid identifies the trigger installation across every submission, e.response.getId() identifies the individual FormResponse.
recordWorkshopSubmit rejects missing events, Sheets-shaped events, and manual toolbar runs before checking that e.source.getId() and e.triggerUid match the saved form and trigger IDs. Note that programmatic FormResponse.submit() calls create a response in Forms but do not fire installable submit triggers; Google's trigger restrictions distinguish script or API writes from user submissions.
Match answers by item ID, not array position
A FormResponse represents the entire submission, and each ItemResponse pairs one question item with its submitted value. The handler reads itemResponse.getItem().getId(), looks up the saved item key, verifies the item type, and stores the answer in a Map. Keying answers by item ID lets the handler read route or mapsNote without assuming a fixed array index in getItemResponses().
Although getItemResponses() follows form order, omitted questions do not behave uniformly: blank text and paragraph items return empty strings, whereas skipped items on unvisited branches can be omitted from the array altogether, and page breaks never produce ItemResponse entries. See FormResponse.getItemResponses.
For text, paragraph text, and multiple-choice items, itemResponse.getResponse() returns a string. Other item types return different shapes: checkboxes return a string array, grids return a row-indexed array, and checkbox grids return nested arrays. The handler requires a string value rather than coercing an unexpected array into comma-joined text. See ItemResponse.getResponse.
To keep logs minimal, the summary records the response ID, ISO submission timestamp, selected branch, feedback character count (String.length in UTF-16 code units), answer-key match boolean, and observed score, while omitting the alias text, feedback prose, and edit-response URL.
Make two optional, reader-owned submissions
If your account is permitted to publish a restricted test form and run an installable trigger, you can submit the two sample fixtures below through the responder UI.
Before publishing, open Responses > More and confirm Get email notifications for new responses is off. In Publish > Manage, restrict responder access to your single test account, leave Notify these responders unchecked, and click Publish (not Publish and notify). After following the publishing controls, run inspectWorkshopProject to verify published: true and acceptingResponses: true.
Use Published > Copy responder link to open the responder view under the same test account.
Use these two fixtures for the responder exercise:
[
{
"alias": "Comet",
"route": "Maps lab",
"mapsNote": "Map labels made the route clear.",
"check": "Review the recipient and content"
},
{
"alias": "Cedar",
"route": "Automation lab",
"automationNote": "The checkpoint example was useful.",
"check": "Keep retrying until something is sent"
}
]
Exercise: predict, change, submit, observe, and restore
Trace the
Cometfixture before submitting it: choosingMaps labroutes to the Maps feedback page, then the shared check. The feedback sentence has 32 characters andpracticeAnswerMatchesKeyistrue.Fill the first response in the responder view and click Submit once. Check Responses > Individual in the form editor, then open Executions in the script editor and inspect
recordWorkshopSubmit. ExpectreceivedResponseIdfollowed byRESPONSE_RECORDEDwith that same response ID and the predicted summary.Set
WORKSHOP.responseIdto that logged response ID and runinspectWorkshopResponsesto read the stored response directly. Next, runrecordSelectedWorkshopResponsewith the same ID; expectALREADY_RECORDEDwith no duplicate receipt. ClearresponseIdafterward.Open a fresh responder view and submit the
Cedarfixture. ChoosingAutomation labroutes to the Automation feedback page (34 characters) and an incorrect check answer (practiceAnswerMatchesKey: false). Verify in Executions that the new submission has a distinctresponseIdwhile the form, item, and trigger IDs are unchanged.Turn off accepting responses, unpublish the form, and wait for active runs to finish. In the form editor, change the Maps note help text from
One short fictional sentence. No personal information.toUse one fictional maps observation. No personal information.RuninspectWorkshopProjectto confirm that editing help text preserves the savedmapsNoteitem ID, then restore the original help text in the UI.Leave all
WORKSHOPstrings empty andrecoveryItemIdasnull, keep the form unpublished and closed to responses, and remove the trigger when finished using the cleanup section below.
If you cannot publish a form in your environment, trace both fixtures against extractVerifiedSubmission_ in Code.gs and identify where a submission can exist in Forms before its ID is written to User Properties.
Retrieve known responses without scanning the store
form.getResponse(responseId) fetches one submitted response by ID and throws if the ID does not exist. When WORKSHOP.responseId is empty, inspectWorkshopResponses looks up only the IDs saved in responseIds (at most five). If responseIds is empty, it makes zero getResponse calls; that means no receipt is recorded locally, not that the form has zero submissions.
By contrast, form.getResponses() loads every response in the form, and form.getResponses(timestamp) filters by start time without providing a page size limit. This project calls neither method. See Form.getResponse.
A submitted response's getId() and getTimestamp() are distinct from its edit link. The script never calls getEditResponseUrl(), because an edit URL lets anyone holding the link modify that submission while response editing is enabled.
A destination Sheet is another copy, not a prerequisite
Forms stores submissions in its own backing store whether or not a spreadsheet is linked. Calling form.setDestination(FormApp.DestinationType.SPREADSHEET, spreadsheetId) copies responses into a Sheet with its own sharing permissions, column layout, and Sheets onFormSubmit trigger shape, without changing how FormApp stores responses or assigns item IDs.
This project requires an unlinked form and stops if a destination is attached. Note that form.removeDestination() unlinks future submissions but leaves previously copied rows in the spreadsheet; see Form.removeDestination and View and manage form responses.
Preserve setup identity across incomplete runs
The JSON record stored under forms-workflows.v1 in User Properties tracks the form ID, expected title, eight item IDs, trigger state and ID, and up to five response IDs. User Properties are scoped to the current user in this script project and do not appear in the Script Properties table in Project Settings. See the Properties service guide.
Setup checkpoints its state before and after each creation step:
| Saved state | Meaning | Allowed continuation |
|---|---|---|
| No record | No setup history exists for this user and project | Confirmed setupWorkshopForm may create the form |
phase: CREATE_UNCERTAIN | Intent was saved before FormApp.create, but no formId checkpoint completed | Inspect and adopt the created form; do not call create again |
phase: BUILDING, empty pendingItem | The form ID and all appended items so far are checkpointed | Inspect, then rerun setupWorkshopForm while unpublished to resume |
phase: BUILDING, nonempty pendingItem | An item append started without completing its ID checkpoint | Inspect and adopt the trailing item, or stop |
phase: READY | All eight items and form settings completed their final checkpoint | Reuse the existing form without rebuilding |
The generated form title includes a UUID so you can recognize a form created during an interrupted run, though the title is only a human cue rather than a provider idempotency key. Because setupWorkshopForm records pendingItem before appending each item and saves the returned item ID immediately afterward, resuming from BUILDING can reapply titles, choices, and routing to known items without appending duplicates.
Mutating entrypoints acquire LockService.getUserLock() via tryLock(1000) and release it in finally, serializing concurrent runs by the same user in this project. See LockService.getUserLock.
Reconcile an unknown form or item
If setupWorkshopForm fails, clear setupConfirmation and run inspectWorkshopProject under the same account to log the saved property record.
When phase is CREATE_UNCERTAIN, check Forms or Drive for an unpublished, empty form matching the saved formTitle created by your attempt. Copy its form ID from the editor URL between /forms/d/ and /edit. Set recoveryFormId to that ID, set recoveryConfirmation to ADOPT FORM <id> FOR <saved formTitle> (with the actual values), and run adoptWorkshopForm to transition the record to BUILDING. Clear both recovery fields, run inspectWorkshopProject, and then rerun setupWorkshopForm with setupConfirmation to finish configuring the items.
When phase is BUILDING with a nonempty pendingItem, compare the saved itemIds against the live items logged by inspectWorkshopProject. If there is one extra trailing item whose type matches pendingItem and all preceding item IDs and types match, set recoveryItemId to that item's integer ID (as a number, not a string), set recoveryConfirmation to ADOPT ITEM <id> AS <pendingItem>, and run adoptWorkshopItem. Clear both recovery fields, inspect, and rerun setupWorkshopForm to finish applying settings to the adopted item.
If no matching form or trailing item exists, or if multiple candidates or unexpected items appear, stop and keep the saved record for inspection rather than creating a replacement.
Interpret trigger and receipt failures
Trigger installation tracks NONE, CREATE_UNCERTAIN, READY, and REMOVED. If an installation run fails after recording CREATE_UNCERTAIN, keep the form unpublished and inspect the project's triggers. If a single matching trigger exists, rerunning installWorkshopTrigger with confirmation adopts its ID and marks READY; if no matching trigger is visible, the installer stops rather than creating a possible duplicate.
Because Forms stores a submission before recordWorkshopSubmit runs, a handler failure never loses the response in Forms. The handler logs receivedResponseId first, then acquires the user lock, validates the form, settings, and answers, appends the ID to responseIds, and logs the summary.
If the handler fails after logging receivedResponseId and you have resolved the cause, set WORKSHOP.responseId to that ID, run inspectWorkshopResponses, and then run recordSelectedWorkshopResponse. That function loads form.getResponse(id) and runs the same receipt validation and property update without faking a trigger event, reporting ALREADY_RECORDED if the earlier property write had already succeeded. Clear responseId afterward.
Handle limits without widening the exercise
This project is sized for one eight-item practice form: inspection calls form.getItems() on the eight items, trigger checks call ScriptApp.getProjectTriggers() for the current user's project, and inspectWorkshopResponses fetches at most five known response IDs.
The state record enforces an application cap of five response IDs and 8,000 UTF-8 bytes so the serialized JSON stays well within Apps Script quotas for User Properties. Keep the exercise to the two sample fixtures.
| Symptom | Response |
|---|---|
| Consent denied or an organization restriction blocks Forms | Resolve permission under the intended account and inspect saved state before resuming. |
| Publication support, title, destination, item type/order, or ID check fails | Inspect the practice form and compare its items and settings against inspectWorkshopProject. |
| The wrong feedback page appears | Stop submissions, close and unpublish the form, and check the choice destinations and PageBreakItem.setGoToPage targets. |
| Handler run from the editor rejects its event | Submit through the published responder view to fire the Forms trigger, or pass a known responseId to recordSelectedWorkshopResponse. |
| A response appears in Forms but no receipt is recorded | Check Executions for receivedResponseId, verify form settings and saved state, and reconcile with recordSelectedWorkshopResponse. |
Score is not_available | Re-inspect the same response ID later with inspectWorkshopResponses instead of submitting again or calling submitGrades(). |
| Lock, quota, service, or property failure | Wait for active executions to finish, then run inspectWorkshopProject before taking the next recovery step. |
| More than five IDs or invalid state | Stop further test submissions and keep the existing state record intact. |
Retire only the identified practice resources
Once you have completed the exercise and resolved any pending state, clear all confirmation fields in WORKSHOP. In the form editor, turn off accepting responses, unpublish the form, and verify both states after waiting for any active executions to finish.
Run inspectWorkshopProject to read the saved trigger ID. Set WORKSHOP.removeTriggerConfirmation to REMOVE TRIGGER <savedId> with that exact ID, run removeWorkshopTrigger under the installer account, and clear the confirmation. The function verifies the trigger's source, handler, and event type before calling ScriptApp.deleteTrigger, then records triggerState: "REMOVED". Confirm on the Triggers page that the entry is gone.
If you want to discard the practice form afterward, verify its saved formId and move that single form to Trash in Drive. You can also trash the standalone script project and revoke its OAuth grant via the authorization guide.
For workflows that send email notifications or confirmations on submission, see respond to a form submission and send a confirmation to the form respondent. Next, build and update a two-slide Google Slides briefing deck.
Project files
Complete source files for this chapter’s examples.
Build and observe a fictional workshop form
Code.gs
Download file/**
* @typedef {'alias'|'route'|'mapsPage'|'mapsNote'|'automationPage'|'automationNote'|'checkPage'|'check'} WorkshopItemKey
*/
/**
* @typedef {'TEXT'|'MULTIPLE_CHOICE'|'PAGE_BREAK'|'PARAGRAPH_TEXT'} WorkshopItemType
*/
/**
* @typedef {{key: WorkshopItemKey, type: WorkshopItemType, title: string}} WorkshopItemDefinition
*/
/**
* @typedef {{
* phase: string,
* formTitle: string,
* formId: string,
* itemIds: {[key: string]: number},
* pendingItem: string,
* triggerState: string,
* triggerId: string,
* responseIds: string[]
* }} WorkshopState
*/
const WORKSHOP = {
setupConfirmation: '', // CREATE ONE PRIVATE WORKSHOP FORM
installConfirmation: '', // INSTALL ONE WORKSHOP TRIGGER
recoveryFormId: '',
recoveryItemId: null,
recoveryConfirmation: '',
responseId: '',
removeTriggerConfirmation: '',
};
const WORKSHOP_STATE_KEY = 'forms-workflows.v1';
const WORKSHOP_HANDLER = 'recordWorkshopSubmit';
const MAX_WORKSHOP_RECEIPTS = 5;
/** @type {WorkshopItemDefinition[]} */
const WORKSHOP_ITEMS = [
{ key: 'alias', type: 'TEXT', title: 'Practice alias, not your real name' },
{ key: 'route', type: 'MULTIPLE_CHOICE', title: 'Which workshop did you try?' },
{ key: 'mapsPage', type: 'PAGE_BREAK', title: 'Maps lab feedback' },
{ key: 'mapsNote', type: 'PARAGRAPH_TEXT', title: 'One useful maps idea' },
{ key: 'automationPage', type: 'PAGE_BREAK', title: 'Automation lab feedback' },
{ key: 'automationNote', type: 'PARAGRAPH_TEXT', title: 'One useful automation idea' },
{ key: 'checkPage', type: 'PAGE_BREAK', title: 'A one-point practice check' },
{ key: 'check', type: 'MULTIPLE_CHOICE', title: 'What should happen before a script sends an email?' },
];
const MAPS_CHOICE = 'Maps lab';
const AUTOMATION_CHOICE = 'Automation lab';
const CORRECT_CHECK = 'Review the recipient and content';
const INCORRECT_CHECK = 'Keep retrying until something is sent';
function setupWorkshopForm() {
if (WORKSHOP.setupConfirmation !== 'CREATE ONE PRIVATE WORKSHOP FORM') {
throw new Error('Read the setup and effects, then supply the exact setup confirmation.');
}
withWorkshopLock_(() => {
let state = readWorkshopState_();
if (!state) {
state = {
phase: 'CREATE_UNCERTAIN',
formTitle: 'Book workshop practice [' + Utilities.getUuid() + ']',
formId: '',
itemIds: {},
pendingItem: '',
triggerState: 'NONE',
triggerId: '',
responseIds: [],
};
saveWorkshopState_(state); // Intent before creating any form.
const created = FormApp.create(state.formTitle, false); // Explicitly unpublished.
state.formId = created.getId();
state.phase = 'BUILDING';
saveWorkshopState_(state); // Identity before configuring or adding items.
}
if (state.phase === 'CREATE_UNCERTAIN') {
throw new Error('Form creation is uncertain. Inspect the state and confirm the existing identity.');
}
const form = openWorkshopForm_(state);
if (state.phase === 'READY') {
checkWorkshopItems_(form, state, 0);
console.log(JSON.stringify({ action: 'REUSED_READY_FORM', formId: state.formId }));
return;
}
requireUnpublishedWorkshop_(form);
if (state.pendingItem) {
throw new Error('An item append is uncertain: ' + state.pendingItem + '. Do not append again.');
}
checkWorkshopItems_(form, state, 0);
form.setAcceptingResponses(false);
form.setIsQuiz(true);
form.setCollectEmail(false);
form.setAllowResponseEdits(false);
form.setLimitOneResponsePerUser(false);
form.setPublishingSummary(false);
form.setShuffleQuestions(false);
form.setProgressBar(true);
form.setDescription('Fictional workshop practice only. No names, email addresses or live feedback.');
form.setConfirmationMessage('Your practice response is saved. No email is sent by this project.');
for (const definition of WORKSHOP_ITEMS) {
if (Object.prototype.hasOwnProperty.call(state.itemIds, definition.key)) continue;
state.pendingItem = definition.key;
saveWorkshopState_(state); // Each append has its own intent and ID checkpoint.
const item = appendWorkshopItem_(form, definition.type);
state.itemIds[definition.key] = item.getId();
state.pendingItem = '';
saveWorkshopState_(state); // Save before setting the item's title or options.
}
const items = checkWorkshopItems_(form, state, 0);
for (let index = 0; index < WORKSHOP_ITEMS.length; index++) {
items[index].setTitle(WORKSHOP_ITEMS[index].title);
}
configureWorkshopQuestions_(form, state);
state.phase = 'READY';
saveWorkshopState_(state);
console.log(JSON.stringify({ action: 'SETUP_READY', formId: state.formId, editUrl: form.getEditUrl(), items: 8 }));
});
}
function inspectWorkshopProject() {
const state = readWorkshopState_();
console.log(JSON.stringify({ savedState: state }));
if (!state || !state.formId) return;
const form = openWorkshopForm_(state);
const items = form.getItems(); // All items, not a paginated API. Use only this small new form.
if (items.length > WORKSHOP_ITEMS.length) throw new Error('More than eight items exist. Stop and inspect the form.');
console.log(JSON.stringify({
formId: state.formId,
editUrl: form.getEditUrl(),
published: form.isPublished(),
acceptingResponses: form.isAcceptingResponses(),
quiz: form.isQuiz(),
collectingEmail: form.collectsEmail(),
responseEditing: form.canEditResponse(),
items: items.map((/** @type {FormApp.Item} */ item) => ({ id: item.getId(), type: String(item.getType()), title: item.getTitle() })),
currentUserTriggers: ScriptApp.getProjectTriggers().map((trigger) => ({
id: trigger.getUniqueId(),
handler: trigger.getHandlerFunction(),
sourceId: trigger.getTriggerSourceId(),
eventType: String(trigger.getEventType()),
})),
}));
}
function adoptWorkshopForm() {
withWorkshopLock_(() => {
const state = requireWorkshopState_();
if (state.phase !== 'CREATE_UNCERTAIN') throw new Error('No unknown form identity is awaiting adoption.');
const id = WORKSHOP.recoveryFormId;
if (!isWorkshopId_(id) || WORKSHOP.recoveryConfirmation !== 'ADOPT FORM ' + id + ' FOR ' + state.formTitle) {
throw new Error('Inspect your new form and its ownership, then supply its ID and exact ADOPT FORM confirmation.');
}
const form = FormApp.openById(id);
requireUnpublishedWorkshop_(form);
if (form.getTitle() !== state.formTitle || form.getItems().length !== 0 || form.getDestinationId()) {
throw new Error('Candidate must be the inspected empty unpublished form from this attempt. Names alone are not proof.');
}
// The human confirmation establishes provenance/ownership; FormApp has no owner getter.
state.formId = form.getId();
state.phase = 'BUILDING';
saveWorkshopState_(state);
console.log(JSON.stringify({ action: 'FORM_ID_ADOPTED', formId: state.formId }));
});
}
function adoptWorkshopItem() {
withWorkshopLock_(() => {
const state = requireWorkshopState_();
if (state.phase !== 'BUILDING' || !state.pendingItem) throw new Error('No uncertain item append is awaiting adoption.');
const form = openWorkshopForm_(state);
requireUnpublishedWorkshop_(form);
const items = checkWorkshopItems_(form, state, 1);
const candidate = items[items.length - 1];
const definition = WORKSHOP_ITEMS[items.length - 1];
if (definition.key !== state.pendingItem || candidate.getId() !== WORKSHOP.recoveryItemId ||
WORKSHOP.recoveryConfirmation !== 'ADOPT ITEM ' + candidate.getId() + ' AS ' + state.pendingItem) {
throw new Error('Inspect the one unrecorded appended item and supply its exact ID and ADOPT ITEM confirmation.');
}
state.itemIds[state.pendingItem] = candidate.getId();
state.pendingItem = '';
saveWorkshopState_(state);
console.log(JSON.stringify({ action: 'ITEM_ID_ADOPTED', itemId: candidate.getId() }));
});
}
function installWorkshopTrigger() {
if (WORKSHOP.installConfirmation !== 'INSTALL ONE WORKSHOP TRIGGER') {
throw new Error('Inspect the ready form and supply the exact trigger-install confirmation.');
}
withWorkshopLock_(() => {
const state = requireReadyWorkshop_();
if (state.triggerState === 'REMOVED') throw new Error('This exercise trigger was retired. Do not reset its history.');
const form = openWorkshopForm_(state);
requireUnpublishedWorkshop_(form); // Prevent submission during installation/checkpointing.
checkWorkshopItems_(form, state, 0);
requireWorkshopResponseSettings_(form);
const triggers = ScriptApp.getProjectTriggers();
if (triggers.length > 1 || (triggers.length === 1 && !isWorkshopTrigger_(triggers[0], state))) {
throw new Error('This private project must contain only the one matching Forms trigger. Inspect; do not bulk-delete.');
}
if (triggers.length === 1) {
const id = triggers[0].getUniqueId();
if (state.triggerId && state.triggerId !== id) throw new Error('Saved and current trigger IDs differ. Stop and reconcile.');
if (state.triggerState !== 'READY') {
state.triggerId = id;
state.triggerState = 'READY';
saveWorkshopState_(state);
}
console.log(JSON.stringify({ action: 'REUSED_TRIGGER', triggerId: id }));
return;
}
if (state.triggerState !== 'NONE') throw new Error('Previously attempted trigger is missing. Do not blindly install another.');
state.triggerState = 'CREATE_UNCERTAIN';
saveWorkshopState_(state);
const trigger = ScriptApp.newTrigger(WORKSHOP_HANDLER).forForm(form).onFormSubmit().create();
state.triggerId = trigger.getUniqueId();
state.triggerState = 'READY';
saveWorkshopState_(state);
console.log(JSON.stringify({ action: 'TRIGGER_READY', triggerId: state.triggerId }));
});
}
/**
* @param {{response: FormApp.FormResponse, source: FormApp.Form, triggerUid: string|number}} e
* @returns {void}
*/
function recordWorkshopSubmit(e) {
if (!e || !e.response || typeof e.response.getItemResponses !== 'function' ||
!e.source || typeof e.source.getId !== 'function' || e.triggerUid === undefined) {
throw new Error('This handler needs the installed Forms submit event, not editor Run or a Sheets event.');
}
const responseId = e.response.getId();
if (typeof responseId !== 'string' || !isWorkshopId_(responseId)) {
throw new Error('No submitted response ID is available.');
}
// Preserve an ID for manual retrieval even if recording below fails. Never log the whole event.
console.log(JSON.stringify({ receivedResponseId: responseId }));
withWorkshopLock_(() => {
const state = requireReadyWorkshop_();
if (state.triggerState !== 'READY' || String(e.triggerUid) !== state.triggerId || e.source.getId() !== state.formId) {
throw new Error('Submission source or trigger does not match this user\'s saved project.');
}
requireWorkshopResponseSettings_(e.source);
rememberWorkshopResponse_(state, e.response);
});
}
function recordSelectedWorkshopResponse() {
if (!isWorkshopId_(WORKSHOP.responseId)) throw new Error('Set responseId to an inspected ID from this form\'s log or saved records.');
withWorkshopLock_(() => {
const state = requireReadyWorkshop_();
const form = openWorkshopForm_(state);
requireWorkshopResponseSettings_(form);
rememberWorkshopResponse_(state, form.getResponse(WORKSHOP.responseId));
});
}
function inspectWorkshopResponses() {
const state = requireReadyWorkshop_();
const form = openWorkshopForm_(state);
requireWorkshopResponseSettings_(form);
const ids = WORKSHOP.responseId ? [WORKSHOP.responseId] : state.responseIds;
if (ids.some((id) => !isWorkshopId_(id))) throw new Error('Supply a response ID, not an edit-response URL.');
for (const id of ids) {
const response = form.getResponse(id); // At most five direct lookups, never getResponses().
console.log(JSON.stringify(summarizeWorkshopResponse_(state, response)));
}
console.log(JSON.stringify({ responsesInspected: ids.length, recordedResponses: state.responseIds.length }));
}
function removeWorkshopTrigger() {
withWorkshopLock_(() => {
const state = requireReadyWorkshop_();
if (!state.triggerId || WORKSHOP.removeTriggerConfirmation !== 'REMOVE TRIGGER ' + state.triggerId) {
throw new Error('Supply the exact REMOVE TRIGGER confirmation for the saved ID.');
}
const trigger = ScriptApp.getProjectTriggers().find((candidate) => candidate.getUniqueId() === state.triggerId);
if (trigger) {
if (!isWorkshopTrigger_(trigger, state)) throw new Error('The identified trigger no longer matches. Stop.');
ScriptApp.deleteTrigger(trigger); // Only the explicitly confirmed trigger; never form data.
}
if (state.triggerState !== 'REMOVED') {
state.triggerState = 'REMOVED';
saveWorkshopState_(state);
}
console.log(JSON.stringify({ action: 'TRIGGER_REMOVED', triggerId: state.triggerId }));
});
}
/**
* @param {FormApp.Form} form
* @param {WorkshopState} state
* @returns {void}
*/
function configureWorkshopQuestions_(form, state) {
const alias = requireWorkshopItem_(form, state.itemIds.alias).asTextItem();
alias.setRequired(true).setPoints(0).setHelpText('Use Comet or Cedar. This is not identity verification.');
requireWorkshopItem_(form, state.itemIds.mapsNote).asParagraphTextItem()
.setRequired(true).setPoints(0).setHelpText('One short fictional sentence. No personal information.');
requireWorkshopItem_(form, state.itemIds.automationNote).asParagraphTextItem()
.setRequired(true).setPoints(0).setHelpText('One short fictional sentence. No personal information.');
const mapsPage = requireWorkshopItem_(form, state.itemIds.mapsPage).asPageBreakItem();
const automationPage = requireWorkshopItem_(form, state.itemIds.automationPage).asPageBreakItem();
const checkPage = requireWorkshopItem_(form, state.itemIds.checkPage).asPageBreakItem();
const route = requireWorkshopItem_(form, state.itemIds.route).asMultipleChoiceItem();
route.setRequired(true).setPoints(0).showOtherOption(false).setChoices([
route.createChoice(MAPS_CHOICE, mapsPage),
route.createChoice(AUTOMATION_CHOICE, automationPage),
]);
mapsPage.setGoToPage(FormApp.PageNavigationType.CONTINUE);
// This break controls the END of the preceding Maps page, not the Automation page.
automationPage.setGoToPage(checkPage);
checkPage.setGoToPage(FormApp.PageNavigationType.CONTINUE);
const check = requireWorkshopItem_(form, state.itemIds.check).asMultipleChoiceItem();
check.setRequired(true).setPoints(1).showOtherOption(false).setChoices([
check.createChoice(CORRECT_CHECK, true),
check.createChoice(INCORRECT_CHECK, false),
]);
check.setFeedbackForCorrect(FormApp.createFeedback().setText('Correct. Review the actual recipient and content before sending.').build());
check.setFeedbackForIncorrect(FormApp.createFeedback().setText('An uncertain send must be inspected, not blindly repeated.').build());
}
/**
* @param {FormApp.Form} form
* @param {number} id
* @returns {FormApp.Item}
*/
function requireWorkshopItem_(form, id) {
const item = form.getItemById(id);
if (item === null) throw new Error('A recorded item is missing. Stop and inspect the form.');
return item;
}
/**
* @param {FormApp.Form} form
* @param {WorkshopItemType} type
* @returns {FormApp.Item}
*/
function appendWorkshopItem_(form, type) {
switch (type) {
case 'TEXT': return form.addTextItem();
case 'MULTIPLE_CHOICE': return form.addMultipleChoiceItem();
case 'PAGE_BREAK': return form.addPageBreakItem();
case 'PARAGRAPH_TEXT': return form.addParagraphTextItem();
default: throw new Error('Unsupported workshop item type.');
}
}
/**
* @param {FormApp.Form} form
* @param {WorkshopState} state
* @param {number} extra
* @returns {FormApp.Item[]}
*/
function checkWorkshopItems_(form, state, extra) {
const items = form.getItems();
const knownCount = Object.keys(state.itemIds).length;
if (items.length !== knownCount + extra || items.length > WORKSHOP_ITEMS.length) {
throw new Error('Unexpected item count. Inspect saved and actual IDs; never append to compensate.');
}
for (let index = 0; index < items.length; index += 1) {
const definition = WORKSHOP_ITEMS[index];
if (items[index].getType() !== workshopItemType_(definition.type) ||
(index < knownCount && items[index].getId() !== state.itemIds[definition.key])) {
throw new Error('Item order, type or saved ID changed at ' + definition.key + '. Stop.');
}
}
return items;
}
/**
* @param {WorkshopItemType} type
* @returns {FormApp.ItemType}
*/
function workshopItemType_(type) {
switch (type) {
case 'TEXT': return FormApp.ItemType.TEXT;
case 'MULTIPLE_CHOICE': return FormApp.ItemType.MULTIPLE_CHOICE;
case 'PAGE_BREAK': return FormApp.ItemType.PAGE_BREAK;
case 'PARAGRAPH_TEXT': return FormApp.ItemType.PARAGRAPH_TEXT;
}
}
/**
* @param {WorkshopState} state
* @returns {FormApp.Form}
*/
function openWorkshopForm_(state) {
if (!state.formId) throw new Error('No confirmed form ID is saved. Reconcile creation first.');
const form = FormApp.openById(state.formId);
if (!form.supportsAdvancedResponderPermissions() || form.getTitle() !== state.formTitle || form.getDestinationId()) {
throw new Error('The saved form has unsupported publishing, a changed title or a linked destination. Inspect it.');
}
return form;
}
/**
* @param {FormApp.Form} form
* @returns {void}
*/
function requireUnpublishedWorkshop_(form) {
if (!form.supportsAdvancedResponderPermissions() || form.isPublished()) {
throw new Error('Setup requires the original unpublished practice form. No legacy publication fallback is provided.');
}
}
/**
* @param {FormApp.Form} form
* @returns {void}
*/
function requireWorkshopResponseSettings_(form) {
if (!form.isQuiz() || form.collectsEmail() || form.canEditResponse() || form.isPublishingSummary() || form.getDestinationId()) {
throw new Error('Keep quiz mode on, email collection/response editing/results summary off, and no linked Sheet.');
}
}
/**
* @param {ScriptApp.Trigger} trigger
* @param {WorkshopState} state
* @returns {boolean}
*/
function isWorkshopTrigger_(trigger, state) {
return trigger.getHandlerFunction() === WORKSHOP_HANDLER &&
trigger.getTriggerSource() === ScriptApp.TriggerSource.FORMS &&
trigger.getEventType() === ScriptApp.EventType.ON_FORM_SUBMIT &&
trigger.getTriggerSourceId() === state.formId;
}
/**
* @param {WorkshopState} state
* @param {FormApp.FormResponse} response
* @returns {{responseId: string, submittedAt: string, branch: string, feedbackCharacters: number, practiceAnswerMatchesKey: boolean, observedQuizScore: number|string}}
*/
function summarizeWorkshopResponse_(state, response) {
const id = response.getId();
const timestamp = response.getTimestamp();
if (typeof id !== 'string' || !isWorkshopId_(id) || !timestamp || !Number.isFinite(timestamp.getTime())) {
throw new Error('A submitted response with its own ID and timestamp is required.');
}
/** @type {Map<string, {value: string, itemResponse: FormApp.ItemResponse}>} */
const answers = new Map();
const itemResponses = response.getItemResponses();
if (itemResponses.length > 5) throw new Error('Unexpected response structure; this project has five questions.');
for (const itemResponse of itemResponses) {
const item = itemResponse.getItem();
const definition = WORKSHOP_ITEMS.find((candidate) => state.itemIds[candidate.key] === item.getId());
if (!definition || definition.type === 'PAGE_BREAK' || item.getType() !== FormApp.ItemType[definition.type] || answers.has(definition.key)) {
throw new Error('Unknown, repeated or changed question identity in the response.');
}
const value = itemResponse.getResponse();
if (typeof value !== 'string') throw new Error('These questions return strings; do not flatten a checkbox or grid response.');
answers.set(definition.key, { value: value, itemResponse: itemResponse });
}
const text = (/** @type {string} */ key) => {
const answer = answers.get(key);
return answer ? answer.value : '';
};
const branch = text('route');
if (!text('alias').trim() || ![MAPS_CHOICE, AUTOMATION_CHOICE].includes(branch) ||
![CORRECT_CHECK, INCORRECT_CHECK].includes(text('check'))) {
throw new Error('Required practice answers are absent or outside the supplied choices.');
}
const noteKey = branch === MAPS_CHOICE ? 'mapsNote' : 'automationNote';
const otherKey = branch === MAPS_CHOICE ? 'automationNote' : 'mapsNote';
if (!text(noteKey).trim() || text(otherKey).trim()) throw new Error('Response does not match one completed feedback branch.');
const checkAnswer = answers.get('check');
if (!checkAnswer) throw new Error('The practice check answer is absent.');
const rawScore = checkAnswer.itemResponse.getScore();
return {
responseId: id,
submittedAt: timestamp.toISOString(),
branch: branch,
feedbackCharacters: text(noteKey).length,
practiceAnswerMatchesKey: text('check') === CORRECT_CHECK,
observedQuizScore: typeof rawScore === 'number' && Number.isFinite(rawScore) ? rawScore : 'not_available',
};
}
/**
* @param {WorkshopState} state
* @param {FormApp.FormResponse} response
* @returns {void}
*/
function rememberWorkshopResponse_(state, response) {
const summary = summarizeWorkshopResponse_(state, response);
if (state.responseIds.includes(summary.responseId)) {
console.log(JSON.stringify({ action: 'ALREADY_RECORDED', responseId: summary.responseId }));
return;
}
if (state.responseIds.length >= MAX_WORKSHOP_RECEIPTS) {
throw new Error('Five response IDs are already recorded. The submitted response remains in Forms; stop further practice submissions.');
}
state.responseIds.push(summary.responseId);
saveWorkshopState_(state);
console.log(JSON.stringify({ action: 'RESPONSE_RECORDED', summary: summary }));
}
function requireWorkshopState_() {
const state = readWorkshopState_();
if (!state) throw new Error('Run the deliberate setup first. No saved project exists for this user.');
return state;
}
function requireReadyWorkshop_() {
const state = requireWorkshopState_();
if (state.phase !== 'READY') throw new Error('Finish or reconcile setup before working with triggers or responses.');
return state;
}
function readWorkshopState_() {
const raw = PropertiesService.getUserProperties().getProperty(WORKSHOP_STATE_KEY);
if (raw === null) return null;
/** @type {WorkshopState} */
let state;
try { state = JSON.parse(raw); }
catch (error) { throw new Error('Saved state is invalid JSON. Preserve it; do not reset it.'); }
const invalid = () => { throw new Error('Saved state is invalid. Preserve the form, item, trigger and response IDs.'); };
if (!state || !['CREATE_UNCERTAIN', 'BUILDING', 'READY'].includes(state.phase) ||
typeof state.formTitle !== 'string' || !/^Book workshop practice \[[0-9a-f-]{36}\]$/i.test(state.formTitle) ||
typeof state.formId !== 'string' || !state.itemIds || typeof state.itemIds !== 'object' || Array.isArray(state.itemIds) ||
typeof state.pendingItem !== 'string' || !['NONE', 'CREATE_UNCERTAIN', 'READY', 'REMOVED'].includes(state.triggerState) ||
typeof state.triggerId !== 'string' || !Array.isArray(state.responseIds) || state.responseIds.length > MAX_WORKSHOP_RECEIPTS) invalid();
const count = Object.keys(state.itemIds).length;
if (count > WORKSHOP_ITEMS.length || WORKSHOP_ITEMS.slice(0, count).some((item) => !Number.isInteger(state.itemIds[item.key])) ||
new Set(Object.values(state.itemIds)).size !== count ||
(state.pendingItem && (count >= WORKSHOP_ITEMS.length || state.pendingItem !== WORKSHOP_ITEMS[count].key)) ||
state.responseIds.some((id) => !isWorkshopId_(id)) || new Set(state.responseIds).size !== state.responseIds.length) invalid();
if (state.phase === 'CREATE_UNCERTAIN' ? state.formId !== '' || count !== 0 || state.pendingItem !== '' : !isWorkshopId_(state.formId)) invalid();
if (state.phase === 'READY' && (count !== WORKSHOP_ITEMS.length || state.pendingItem)) invalid();
if (['READY', 'REMOVED'].includes(state.triggerState) ? !isWorkshopId_(state.triggerId) : state.triggerId !== '') invalid();
if (state.phase !== 'READY' && (state.triggerState !== 'NONE' || state.responseIds.length)) invalid();
return state;
}
/**
* @param {WorkshopState} state
* @returns {void}
*/
function saveWorkshopState_(state) {
const text = JSON.stringify(state);
if (Utilities.newBlob(text).getBytes().length > 8000) throw new Error('Saved state exceeds the practice allowance. Do not erase history.');
PropertiesService.getUserProperties().setProperty(WORKSHOP_STATE_KEY, text);
}
/**
* @param {function(): void} operation
* @returns {void}
*/
function withWorkshopLock_(operation) {
const lock = LockService.getUserLock();
if (!lock.tryLock(1000)) throw new Error('Another execution holds this user lock. Inspect its result before repeating work.');
try { operation(); }
finally { lock.releaseLock(); }
}
/**
* @param {string} value
* @returns {boolean}
*/
function isWorkshopId_(value) {
return typeof value === 'string' && value.length > 0 && value.length <= 1024 && !/\s/.test(value);
}
appsscript.json
Download file{
"timeZone": "Etc/UTC",
"dependencies": {},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"oauthScopes": [
"https://www.googleapis.com/auth/forms",
"https://www.googleapis.com/auth/script.scriptapp"
]
}
practice-responses.json
Download file[
{
"alias": "Comet",
"route": "Maps lab",
"mapsNote": "Map labels made the route clear.",
"check": "Review the recipient and content"
},
{
"alias": "Cedar",
"route": "Automation lab",
"automationNote": "The checkpoint example was useful.",
"check": "Keep retrying until something is sent"
}
]