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.
Create a small branching quiz form, tell its submit event apart from a Sheets event, and inspect known responses without creating a second form, trigger, or submission.
Build a workshop feedback form with FormApp. It asks about only the lab the respondent attended, then gives everyone the same one-point practice question. An installable form-submit trigger records each response's ID in User Properties and logs a short summary. The project sends no email, creates no spreadsheet, and copies no answers into another service.
This project builds on runtime and permissions and installing and removing time-driven triggers. You need one test account that can create forms and install triggers, and a new private standalone project.
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 FormApp.create(title) does not create the form unpublished. The script saves the returned form ID, configures the items, and leaves the form closed to responses. It never publishes the form or shares it with anyone. See FormApp.create.
Publishing controls whether responders can open a form. Accepting responses controls whether an open form can receive submissions. Neither setting checks who owns the form. The code calls supportsAdvancedResponderPermissions() before the publication methods and stops if the form does not support them. Changing the publication state can also overwrite the accepting-responses setting, so 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. The two roles differ even when both are the same test account. Naming a function recordWorkshopSubmit does not give it the respondent's permissions or make it a simple trigger.
This form collects no email addresses. Its alias question accepts fictional text such as Comet and does not identify the respondent. A form can require Google sign-in for access without recording the respondent's email address. A free-text email field does not verify an account. 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 responses 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 for the manual responder exercise. Do not add 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. The forms.currentonly scope 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 your test account. If the consent screen lets you grant permissions one at a time, grant both. Setup needs the Forms permission, and the installer needs the trigger permission. A trigger that runs unattended cannot ask for missing consent. Resolve any permission problem by hand under your test account and inspect the 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, which make four pages. A page break is an Item in form.getItems() even though it is not a question. The script maps its own keys (alias, route, and so on) to the integer item IDs that Forms assigns. It does not rely on an item's array index or title, because both can change when someone edits the form.
A generic Item has shared methods such as getId, getType, and setTitle. To configure choices, the script checks that the item type is MULTIPLE_CHOICE and then calls asMultipleChoiceItem(), which throws when called on another item type. See the Item reference. Because setup records each item's ID, type, and position, it can reject a 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 confirm that a second run creates nothing new, set setupConfirmation again, run setupWorkshopForm, 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
A multiple-choice option can carry a page destination as well as a label. setChoiceValues(['Maps lab', 'Automation lab']) would set plain labels. This project instead calls route.createChoice(label, pageBreakItem) for each branch and passes both Choice objects to setChoices.
When the respondent clicks Next at the end of the first page, Maps lab sends them to mapsPage and Automation lab sends them to automationPage. Both choices carry navigation, and Other is disabled. The MultipleChoiceItem reference forbids mixing navigation and non-navigation choices in one item.
After the Maps feedback page, the form must skip the Automation feedback page. A page break's setGoToPage sets the destination after the page before that break. It does not set the destination after the new page. In this form:
automationPage.setGoToPage(checkPage)sends a respondent from the end of the Maps page to the check page.checkPage.setGoToPage(FormApp.PageNavigationType.CONTINUE)lets a respondent continue from the Automation page into the check.
mapsPage.setGoToPage(...) would apply at the end of page one, but the route choice's own destination overrides it there. 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. Page navigation controls which pages the respondent sees, not what a submission can contain. The submit handler checks the chosen branch itself, requires that branch's note, and rejects a nonempty note for the other branch. If you type a note, click Back, and choose the other lab, the submission can contain both notes and the handler rejects it. Start each test submission in a fresh responder session.
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 script marks the choice Review the recipient and content as correct and Keep retrying until something is sent as incorrect. It gives correct and incorrect answers distinct feedback, built with FormApp.createFeedback().setText(...).build().
check calls createChoice(label, true), the correct-answer form of the method, while route calls createChoice(label, pageBreakItem), the page-navigation form. No overload of createChoice takes both a destination page and a correctness boolean, so the form keeps routing and grading in separate questions. Forms grades multiple-choice quiz items automatically (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). 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. A score that setScore sets on an ItemResponse does not persist until a script submits it through Form.submitGrades(). The 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 Apps Script 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, the FORMS trigger source, the ON_FORM_SUBMIT event type, and the source form ID. It 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 source:
| 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, and 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. 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
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().
getItemResponses() follows form order, but unanswered questions do not all look the same. A blank text or paragraph item returns an empty string. A question on a page the respondent never visited can be missing from the array. 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. It rejects an unexpected array instead of joining it into comma-separated text. See ItemResponse.getResponse.
The log summary records the response ID, the ISO submission timestamp, the selected branch, the feedback length as String.length counts it, whether the check answer matched the key, and the observed score. It omits the alias, the feedback text, and the edit-response URL.
Optional: submit two test responses
If your account is permitted to publish a restricted test form and run an installable trigger, you can submit the two sample responses 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). See the publishing controls. Then run inspectWorkshopProject to verify published: true and acceptingResponses: true.
Use Published > Copy responder link to open the responder view under your test account.
Use these two sample responses 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: submit and inspect two responses
Trace the
Cometsample response 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 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
Cedarsample response. ChoosingAutomation labroutes to the Automation feedback page. The feedback sentence has 34 characters, and the check answer is incorrect (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 sample responses against extractVerifiedSubmission_ in Code.gs. Identify the step where a submission can exist in Forms before the handler writes its ID 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. An empty list means only that no receipt is saved in User Properties. The form itself can still have submissions.
By contrast, form.getResponses() loads every response in the form. form.getResponses(timestamp) filters by start time but has no 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.
Linking a response spreadsheet
Forms keeps its own copy of every submission whether a spreadsheet is linked or not. Calling form.setDestination(FormApp.DestinationType.SPREADSHEET, spreadsheetId) copies responses into a Sheet with its own sharing permissions, column layout, and Sheets onFormSubmit trigger shape. It does not change how FormApp stores responses or assigns item IDs.
This project requires an unlinked form and stops if a destination is attached. form.removeDestination() unlinks future submissions but leaves previously copied rows in the spreadsheet. See Form.removeDestination and View and manage form responses.
Resume setup after a failed run
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 | A marker 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. The title helps you find the form, but the script does not use it to detect duplicates. setupWorkshopForm records pendingItem before appending each item and saves the returned item ID immediately afterward. Resuming from BUILDING can therefore reapply titles, choices, and routing to known items without appending duplicates.
Every function that changes the form or the saved state takes LockService.getUserLock() with tryLock(1000), which waits up to one second for the lock. It releases the lock in finally. A second run by the same user that cannot get the lock stops with an error instead of working on the same state. See LockService.getUserLock.
Reconcile an unknown form or item
If setupWorkshopForm fails, clear setupConfirmation and run inspectWorkshopProject under your test 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. Check whether there is one extra trailing item whose type matches pendingItem while all preceding item IDs and types match. If so, 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. Do not create 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, and the respondent does not need to submit again. The handler logs receivedResponseId first, then acquires the user lock and validates the form, settings, and answers. Finally, it 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. It reports ALREADY_RECORDED if the earlier property write had already succeeded. Clear responseId afterward.
Limits in this project
This project is sized for one eight-item practice form. Inspection calls form.getItems() on those eight items, and trigger checks call ScriptApp.getProjectTriggers() for the current user's project. inspectWorkshopResponses fetches at most five known response IDs.
The state record allows at most five response IDs and 8,000 UTF-8 bytes, so the serialized JSON stays well within the Apps Script quotas for User Properties. Submit only the two sample responses.
| Symptom | Response |
|---|---|
| Consent denied or an organization restriction blocks Forms | Resolve permission under your test account and inspect saved state before resuming. |
| Publication support, title, destination, item type, item 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 as described in 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.
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"
}
]