Handling persistent user-specific values in Gmail Add-ons - google-apps-script

I have created simple Gmail addon, now I'm struggling with below strategies.
After installing the addon, when first inbox is opened, we ask basic info input from users, when he opens second mail we won't ask basic info details again.
How I can handle this?
I have tried property service but no luck.
Update
var MAX_THREADS = 5;
/**
* Returns the array of cards that should be rendered for the current
* e-mail thread. The name of this function is specified in the
* manifest 'onTriggerFunction' field, indicating that this function
* runs every time the add-on is started.
*
* #param {Object} e data provided by the Gmail UI.
* #returns {Card[]}
*/
function buildAddOn(e) {
// Activate temporary Gmail add-on scopes.
//Logger.log('E', Session.getActiveUser());
var accessToken = e.messageMetadata.accessToken;
GmailApp.setCurrentMessageAccessToken(accessToken);
var userProperties = PropertiesService.getUserProperties();
var Token = userProperties.getProperty('Token');
Logger.log('Token value:',typeof Token);
if(Token != null ){
var messageId = e.messageMetadata.messageId;
var senderData = extractSenderData(messageId);
var cards = [];
// Build a card for each recent thread from this email's sender.
if (senderData.recents.length > 0) {
senderData.recents.forEach(function(threadData) {
cards.push(buildRecentThreadCard(senderData.email, threadData));
});
} else {
// Present a blank card if there are no recent threads from
// this sender.
cards.push(CardService.newCardBuilder()
.setHeader(CardService.newCardHeader()
.setTitle('No recent threads from this sender')).build());
}
return cards;
}
else{
var cards = []
var login_card = build_login_card()
cards.push(login_card);
return cards;
}
}
/**
* This function builds a set of data about this sender's presence in your
* inbox.
*
* #param {String} messageId The message ID of the open message.
* #return {Object} a collection of sender information to display in cards.
*/
function extractSenderData(messageId) {
// Use the Gmail service to access information about this message.
var mail = GmailApp.getMessageById(messageId);
var threadId = mail.getThread().getId();
var senderEmail = extractEmailAddress(mail.getFrom());
var recentThreads = GmailApp.search('from:' + senderEmail);
var recents = [];
// Retrieve information about up to 5 recent threads from the same sender.
recentThreads.slice(0,MAX_THREADS).forEach(function(thread) {
if (thread.getId() != threadId && ! thread.isInChats()) {
recents.push({
'subject': thread.getFirstMessageSubject(),
'count': thread.getMessageCount(),
'link': 'https://mail.google.com/mail/u/0/#inbox/' + thread.getId(),
'lastDate': thread.getLastMessageDate().toDateString()
});
}
});
var senderData = {
"email": senderEmail,
'recents': recents
};
return senderData;
}
/**
* Given the result of GmailMessage.getFrom(), extract only the email address.
* getFrom() can return just the email address or a string in the form
* "Name <myemail#domain>".
*
* #param {String} sender The results returned from getFrom().
* #return {String} Only the email address.
*/
function extractEmailAddress(sender) {
var regex = /\<([^\#]+\#[^\>]+)\>/;
var email = sender; // Default to using the whole string.
var match = regex.exec(sender);
if (match) {
email = match[1];
}
return email;
}
/**
* Builds a card to display information about a recent thread from this sender.
*
* #param {String} senderEmail The sender email.
* #param {Object} threadData Infomation about the thread to display.
* #return {Card} a card that displays thread information.
*/
function buildRecentThreadCard(senderEmail, threadData) {
var card = CardService.newCardBuilder();
card.setHeader(CardService.newCardHeader().setTitle(threadData.subject));
var section = CardService.newCardSection()
.setHeader("<font color=\"#1257e0\">Recent thread</font>");
section.addWidget(CardService.newTextParagraph().setText(threadData.subject));
section.addWidget(CardService.newKeyValue()
.setTopLabel('Sender')
.setContent(senderEmail));
section.addWidget(CardService.newKeyValue()
.setTopLabel('Number of messages')
.setContent(threadData.count.toString()));
section.addWidget(CardService.newKeyValue()
.setTopLabel('Last updated')
.setContent(threadData.lastDate.toString()));
var threadLink = CardService.newOpenLink()
.setUrl(threadData.link)
.setOpenAs(CardService.OpenAs.FULL_SIZE);
var button = CardService.newTextButton()
.setText('Open Thread')
.setOpenLink(threadLink);
section.addWidget(CardService.newButtonSet().addButton(button));
card.addSection(section);
return card.build();
}
function build_login_card(){
var card = CardService.newCardBuilder();
card.setHeader(CardService.newCardHeader().setTitle("Login Here"));
var userProperties = PropertiesService.getUserProperties();
var Token = userProperties.setProperty('Token',"Token");
return card.build()
}

According to comments, the primary issue here is that uninstalling the add-on does not remove bits from the UserProperties datastore, and thus if the add-on is re-installed, the add-on configuration steps cannot be performed again.
Generic Add-on Solution
If this were not a Gmail add-on, this could be simply solved with a time-based trigger, since per documentation:
Add-on triggers will stop firing in any of the following situations:
- If the add-on is uninstalled by the user
- If the add-on is disabled in a document (if it is re-enabled, the trigger will become operational again)
- If the developer unpublishes the add-on or submits a broken version to the add-on store
I.e., you would simply write the day's date to a key (e.g. LAST_SEEN) in PropertiesService#UserProperties every day, and then check both TOKEN and LAST_SEEN when deciding which card to display.
Gmail Add-on Solution:
Per Gmail add-on documentation, you cannot create or use Apps Script simple / installable triggers in a Gmail add-on. So, the easiest implementation of the solution is not available. However, we can still use this approach, by moving the region in which that LAST_SEEN key is updated.
Right now (2018 March), the only available trigger for a Gmail add-on is the contextual trigger unconditional:
Currently, the only contextual trigger type available is unconditional, which triggers for all emails regardless of content.
So, if you bind to this contextual trigger, while your add-on is installed it will run a function whenever the user opens an email. The solution is then for your contextually triggered function to include the snippet
PropertiesService.getUserProperties().setProperty("LAST_SEEN", String(new Date().getTime()));
If you have other stuff to do in your contextually triggered function, that code will be uninfluenced by this addition. If you don't have a contextually triggered function, then you'd want to return [] (an empty card stack) in order to avoid showing any UI.
To use this new property, in your buildAddon(e) method you want to query for this value in addition to the TOKEN property you are using:
var userProps = PropertiesService.getUserProperties().getAll();
var Token = userProps["Token"];
var lastSeen = userProps["LAST_SEEN"] || 0; // If found, will be milliseconds since epoch.
var absence = new Date().getTime() - lastSeen; // Time in ms since last use of add-on.
if (Token == null || absence > /* some duration you choose */ ) {
// New install, or user has started using app again.
return [build_login_card()];
} else {
// User is still using add-on, so do normal stuff.
}
This is obviously not a perfect solution (i.e. an uninstall contextual trigger would be much better), but can help detect lack-of-use situations
There are rate limits on how often you can write to PropertiesService. If you have speedy/"power" users, they might trip quotas.
Could combine CacheService and PropertiesService to handle frequent reads in a "short" session (of up to 6hr since last storage into cache).

Robert , have you tried caching the user input ?
I do caching in the event handler.
function onDomainChange(e){
var cache = CacheService.getScriptCache();
Logger.log(e.formInput);
cache.put('domain',e.formInput.domain);
Logger.log(cache.get('domain'));
}
Refer cache docs

Related

Google Apps Scripts Update Subject and CC with compose at same time

I would like to open the compose UI and be able to update the draft subject / recipients / and CC all at the same time, not in multiple operations.
The sample code Google gives you doesn't work out of the box, you need to correct the errors. Here is the working code.
/**
* Compose trigger function that fires when the compose UI is
* requested. Builds and returns a compose UI for inserting images.
*
* #param {event} e The compose trigger event object. Not used in
* this example.
* #return {Card[]}
*/
function startApp(e) {
return [buildComposeCard()];
}
/**
* Build a card to display interactive buttons to allow the user to
* update the subject, and To, Cc, Bcc recipients.
*
* #return {Card}
*/
function buildComposeCard() {
var card = CardService.newCardBuilder();
var cardSection = CardService.newCardSection().setHeader('Update email');
cardSection.addWidget(
CardService.newTextButton()
.setText('Update subject')
.setOnClickAction(CardService.newAction()
.setFunctionName('applyUpdateSubjectAction')));
cardSection.addWidget(
CardService.newTextButton()
.setText('Update To recipients')
.setOnClickAction(CardService.newAction()
.setFunctionName('applyUpdateToRecipientsAction')));
cardSection.addWidget(
CardService.newTextButton()
.setText('Update Cc recipients')
.setOnClickAction(CardService.newAction()
.setFunctionName('applyUpdateCcRecipientsAction')));
cardSection.addWidget(
CardService.newTextButton()
.setText('Update Bcc recipients')
.setOnClickAction(CardService.newAction()
.setFunctionName('applyUpdateBccRecipientsAction')));
return card.addSection(cardSection).build();
}
/**
* Updates the subject field of the current email when the user clicks
* on "Update subject" in the compose UI.
*
* Note: This is not the compose action that builds a compose UI, but
* rather an action taken when the user interacts with the compose UI.
*
* #return {UpdateDraftActionResponse}
*/
function applyUpdateSubjectAction() {
// Get the new subject field of the email.
// This function is not shown in this example.
var subject = ['this is a subject'];
var response = CardService.newUpdateDraftActionResponseBuilder()
.setUpdateDraftSubjectAction(CardService.newUpdateDraftSubjectAction()
.addUpdateSubject(subject))
.build();
return response;
}
/**
* Updates the To recipients of the current email when the user clicks
* on "Update To recipients" in the compose UI.
*
* Note: This is not the compose action that builds a compose UI, but
* rather an action taken when the user interacts with the compose UI.
*
* #return {UpdateDraftActionResponse}
*/
function applyUpdateToRecipientsAction() {
// Get the new To recipients of the email.
// This function is not shown in this example.
var toRecipients = ['johhny.appleseed#gmail.com'];
var response = CardService.newUpdateDraftActionResponseBuilder()
.setUpdateDraftToRecipientsAction(CardService.newUpdateDraftToRecipientsAction()
.addUpdateToRecipients(toRecipients))
.build();
return response;
}
/**
* Updates the Cc recipients of the current email when the user clicks
* on "Update Cc recipients" in the compose UI.
*
* Note: This is not the compose action that builds a compose UI, but
* rather an action taken when the user interacts with the compose UI.
*
* #return {UpdateDraftActionResponse}
*/
function applyUpdateCcRecipientsAction() {
// Get the new Cc recipients of the email.
// This function is not shown in this example.
var ccRecipients = ['big.blue#montana.com'];
var response = CardService.newUpdateDraftActionResponseBuilder()
.setUpdateDraftCcRecipientsAction(CardService.newUpdateDraftCcRecipientsAction()
.addUpdateCcRecipients(ccRecipients))
.build();
return response;
}
/**
* Updates the Bcc recipients of the current email when the user clicks
* on "Update Bcc recipients" in the compose UI.
*
* Note: This is not the compose action that builds a compose UI, but
* rather an action taken when the user interacts with the compose UI.
*
* #return {UpdateDraftActionResponse}
*/
function applyUpdateBccRecipientsAction() {
// Get the new Bcc recipients of the email.
// This function is not shown in this example.
var bccRecipients = ['spacer#gmail.com'];
var response = CardService.newUpdateDraftActionResponseBuilder()
.setUpdateDraftBccRecipientsAction(CardService.newUpdateDraftBccRecipientsAction()
.addUpdateBccRecipients(bccRecipients))
.build();
return response;
}
When that code is opened, it looks like this compose UI display.
However
these links only work one at a time and close the screen. You have to perform all the actions in multiple moves. I would like it to be able to perform more then one action at a time.
For example, if I click "Update Subject", the action works and the compose UI screen closes, but I don't want to open the add-on a second time to add the CC email address.
I believe your goal as follows.
You want to run these functions of applyUpdateSubjectAction(), applyUpdateToRecipientsAction(), applyUpdateCcRecipientsAction() and applyUpdateBccRecipientsAction() by one click at the dialog.
In this case, how about the following modification?
Modified script:
Please modify buildComposeCard() as follows.
function buildComposeCard() {
var card = CardService.newCardBuilder();
var cardSection = CardService.newCardSection().setHeader('Update email');
cardSection.addWidget(CardService.newTextButton().setText('Update email').setOnClickAction(CardService.newAction().setFunctionName('applyUpdateEmail')));
return card.addSection(cardSection).build();
}
And, please add the following function.
function applyUpdateEmail() {
var subject = ['this is a subject'];
var toRecipients = ['johhny.appleseed#gmail.com'];
var ccRecipients = ['big.blue#montana.com'];
var bccRecipients = ['spacer#gmail.com'];
return CardService.newUpdateDraftActionResponseBuilder()
.setUpdateDraftSubjectAction(CardService.newUpdateDraftSubjectAction().addUpdateSubject(subject))
.setUpdateDraftToRecipientsAction(CardService.newUpdateDraftToRecipientsAction().addUpdateToRecipients(toRecipients))
.setUpdateDraftCcRecipientsAction(CardService.newUpdateDraftCcRecipientsAction().addUpdateCcRecipients(ccRecipients))
.setUpdateDraftBccRecipientsAction(CardService.newUpdateDraftBccRecipientsAction().addUpdateBccRecipients(bccRecipients))
.build();
}
In this modification, you can see "Update email" at the opened dialog. When you click it, addUpdateSubject, addUpdateToRecipients, addUpdateCcRecipients and addUpdateBccRecipients are run.
Note:
When you modified the Google Apps Script, please modify the deployment as new version. By this, the modified script is reflected to the add-on. Please be careful this.
References:
Class UpdateDraftActionResponse
Class UpdateDraftActionResponseBuilder

OAuth2 Library Yields Property Storage Quota Error

I have a script using Google's OAuth2 library, which recently started to fail with the error
You have exceeded the property storage quota. Please remove some properties and try again.
I inspected the properties and did not find any unexpected differences from my original configuration. The full stringified text length of my properties is around 5000 characters, which is well below the 500kB/property store quota and the longest individual property ~2500 characters (below the 9kB/value quota).
I've discovered that this error only occurs when I use Google's OAuth2 library with a service account. However, if I include the library dist directly in my project, the error disappears.
Why is there a difference in how the properties service behaves between the library and the local copy if they appear to be the same versions?
(In other scripts where I use the OAuth2 library with a standard authorization code grant flow, I have no issues.)
This script will replicate the error, but requires that you create a service account and set the correct script properties as defined in getAdminDirectory_(). (Using Rhino interpreter.)
/**
* Get a GSuite User.
*/
function getUser() {
var email = "name#exampledomain.com";
var user = AdminDirectory_getUser_(email);
Logger.log(user);
}
/**
* Gets a user from the GSuite organization by their email address.
* #returns {User} - https://developers.google.com/admin-sdk/directory/v1/reference/users#resource
*/
function AdminDirectory_getUser_(email) {
var service = getAdminDirectory_();
if (service.hasAccess()) {
var url = "https://www.googleapis.com/admin/directory/v1/users/" + email;
var options = {
method: "get",
headers: {
Authorization: "Bearer " + service.getAccessToken()
}
};
var response = UrlFetchApp.fetch(url, options);
var result = JSON.parse(response.getContentText());
return result;
} else {
throw service.getLastError();
}
}
/**
* Configures the service.
*/
function getAdminDirectory_() {
// Get service account from properties
var scriptProperties = PropertiesService.getScriptProperties();
var serviceAccount = JSON.parse(scriptProperties.getProperty("service_account"));
// Email address of the user to impersonate.
var user_email = scriptProperties.getProperty("service_account_user");
return OAuth2.createService("AdminDirectory:" + user_email)
// Set the endpoint URL.
.setTokenUrl("https://oauth2.googleapis.com/token")
// Set the private key and issuer.
.setPrivateKey(serviceAccount.private_key)
.setIssuer(serviceAccount.client_email)
// Set the name of the user to impersonate. This will only work for
// Google Apps for Work/EDU accounts whose admin has setup domain-wide
// delegation:
// https://developers.google.com/identity/protocols/OAuth2ServiceAccount#delegatingauthority
.setSubject(user_email)
// Set the property store where authorized tokens should be persisted.
.setPropertyStore(scriptProperties)
// Set the scope. This must match one of the scopes configured during the
// setup of domain-wide delegation.
.setScope("https://www.googleapis.com/auth/admin.directory.user");
}
This seems to be a known issue. Consider adding a star(on top left) to this issue to let Google developers know that you're affected. Consider adding a comment to the tracker with details requested from #7
Possible solutions:
As said in the question, directly use the library code by copy pasting instead of using the buggy library feature.
Switch to v8-#21
Wait for random/Auto resolution -#14

My add-on will not run on messages in the SPAM folder

When I try to activate my add-on for a message in the SPAM folder it tells me "Spam and suspicious messages can’t be used for recommended content or actions." How do I make it work?
function getContextualAddOn(event) {
var message = getCurrentMessage(event);
var card = createCard(message);
return [card.build()];
}
/**
* Retrieves the current message given an action event object.
* #param {Event} event Action event object
* #return {Message}
*/
function getCurrentMessage(event) {
var accessToken = event.messageMetadata.accessToken;
var messageId = event.messageMetadata.messageId;
GmailApp.setCurrentMessageAccessToken(accessToken);
return GmailApp.getMessageById(messageId);
}
function createCard(message) {
var emailFrom = message.getHeader("return-path-1");
var card = CardService.newCardBuilder();
card.setHeader(CardService.newCardHeader().setTitle("Forward e-mail"));
var statusSection = CardService.newCardSection();
statusSection.addWidget(CardService.newTextParagraph()
.setText("<b>Sender: </b>" + emailFrom ));
card.addSection(statusSection);
var formArea = CardService.newCardSection();
var widget = CardService.newTextInput()
.setFieldName("forwardTo")
.setTitle("To:");
formArea.addWidget(widget);
card.addSection(formArea);
return card;
}
Gmail add-ons cannot currently handle emails inside the SPAM folder.
There is an open Feature Request in Issue Tracker regarding this functionality:
Allow GMAIL AddOns To Process emails inside SPAM folder
I'd suggest you to star this issue, in order to prioritize it and to keep track of any updates.
Workarounds:
Move the emails outside the SPAM folder, forward them, or what have you, before interacting with them through the add-on.

Google Calendar events to Google Sheers automatic refresh with onEdit trigger

I am trying to grab time events from my Google Calendar into a Google Spreadsheet.
When a new time-event is created in my Google Calendar this event should be automatically synchronized into my Google Spreadsheet. This should be done automatically by an onEdit event trigger.
At the moment it is only running by refreshing the Google Spreadsheet.
Maybe someone has a better solution for my challenge. Here is my code:
function createSpreadsheetEditTrigger() {
var ss = SpreadsheetApp.getActive();
ScriptApp.newTrigger('myCalendar')
.forSpreadsheet(ss)
.onEdit()
.create();
}
function myCalendar(){
var now=new Date();
// Startzeit
var startpoint=new Date(now.getTime()-60*60*24*365*1000);
// Endzeit
var endpoint=new Date(now.getTime()+60*60*24*1000*1000);
var events=CalendarApp.getCalendarById("your-calendar-ID").getEvents(startpoint, endpoint);
var ss=SpreadsheetApp.getActiveSpreadsheet().getSheetByName("TEST");
ss.clear();
for (var i=0;i<events.length;i++) {
ss.getRange(i+1,1 ).setValue(events[i].getTitle());
ss.getRange(i+1,2).setValue(events[i].getDescription());
ss.getRange(i+1,3).setValue(events[i].getStartTime());
ss.getRange(i+1,4).setValue(events[i].getEndTime());
}
}
Problem
Execute a function updating a spreadsheet when an event in Google Calendar is created.
Solution
Use the EventUpdated installable trigger that is fired each time an event is modified in Calendar (e.g. created, updated, or deleted - see reference). From there, you can go the easy way (update all data in the spreadsheet with a built-in CalendarApp class) or the hard way (update data that was changed with incremental sync - see official guide).
Part 0 - install trigger
/**
* Installs Calendar trigger;
*/
function calendarTrigger() {
var trigger = ScriptApp.newTrigger('callback name here')
.forUserCalendar('calendar owners email here')
.onEventUpdated()
.create();
}
Part 1 - callback (Calendar -> Spreadsheet)
/**
* Updates spreadsheet;
* #param {Object} e event object;
*/
function updateSpreadsheet(e) {
//access spreadsheet;
var ss = SpreadsheetApp.openById('target spreadsheet id');
var sh = ss.getSheetByName('target sheet name');
var datarng = sh.getDataRange(); //assumed that data is only calendar data;
//access calendar;
var calendar = CalendarApp.getCalendarById(e.calendarId);
//set timeframes;
var start = new Date();
var end =new Date();
//get year before and three after;
start.setFullYear(start.getFullYear()-1);
end.setFullYear(end.getFullYear()+3);
//get events;
var events = calendar.getEvents(start, end);
//map events Array to a two-dimensional array of values;
events = events.map(function(event){
return [event.getTitle(),event.getDescription(),event.getStartTime(),event.getEndTime()];
});
//clear values;
datarng.clear();
//setup range;
var rng = sh.getRange(1,1, events.length, events[0].length);
//apply changes;
rng.setValues(events);
}
Notes
As per Tanaike's comment - it is important to account for triggers (both simple and installable) to not firing if event is triggered via script or request (see restrictions reference). To enable such feature you will have to introduce polling or bundle with a WebApp that the script will call after creating an event (see below for a bundling sample).
Your solution is better suited for backwards flow: edit in spreadsheet -> edit in Calendar (if you modify it to perform ops on Calendar instead of updating the spreadsheet, ofc).
Make use of Date built-in object's methods like getFullYear() (see reference for other methods) to make your code more flexible and easier to understand. Btw, I would store "ms in a day" data as a constant (86400000).
Never use getRange(), getValue(), setValue() and similar methods in a loop (and in general call them as little as possible) - they are I/O methods and thus are slow (you can see for yourself by trying to write >200 rows in a loop). Get ranges/values needed at the start, perform modifications and write them in bulk (e.g. with setValues() method).
Reference
EventUpdated event reference;
Calendar incremental synchronization guide;
Date built-in object reference;
setValues() method reference;
Using batch operations in Google Apps Script;
Installable and simple triggers restrictions;
WebApp bundling
Part 0 - prerequisites
If you want to create / update / remove calendar events via script executions, you can bundle the target script with a simple WebApp. You'll need to make sure that:
The WebApp is deployed with access set as anyone, even anonymous (it is strongly recommended to introduce some form of request authentication);
WebApp code has one function named doPost accepting event object (conventionally named e, but it's up to you) as a single argument.
Part 1 - build a WebApp
This build assumes that all modifications are made in the WebApp, but you can, for example, return callback name to run on successfull request and handle updates in the calling script. As only the calendarId property of the event object is used in the callback above, we can pass to it a custom object with only this property set:
/**
* Callback for POST requests (always called "doPost");
* #param {Object} e event object;
* #return {Object} TextOutput;
*/
function doPost(e) {
//access request params;
var body = JSON.parse(e.postData.contents);
//access calendar id;
var calendarId = body.calendar;
if(calendarId) {
updateSpreadsheet({calendarId:calendarId}); //callback;
return ContentService.createTextOutput('Success');
}else {
return ContentService.createTextOutput('Invalid request');
}
}
Part 2 - sample calling script
This build assumes that calling script and the WebApp are the same script project (thus its Url can be accessed via ScriptApp.getService().getUrl(), otherwise paste the one provided to you during WebApp deployment). Being familiar with UrlFetchApp (see reference) is required for the build.
/**
* Creates event;
*/
function createEvent() {
var calendar = CalendarApp.getCalendarById('your calendar id here');
//modify whatever you need to (this build creates a simple event);
calendar.createEvent('TEST AUTO', new Date(), new Date());
//construct request parameters;
var params = {
method: 'post',
contentType: 'application/json',
muteHttpExceptions: true,
payload: JSON.stringify({
calendar: calendar.getId()
})
};
//send request and handle result;
var updated = UrlFetchApp.fetch(ScriptApp.getService().getUrl(),params);
Logger.log(updated); //should log "Success";
}
enter code here
// There are can be many calendar in one calendar of user like his main calendar, created calendar, holiday, etc.
// This function will clear all previous trigger from each calendar of user and create new trigger for remaining calendar of the user.
function createTriggers() {
clearAllTriggers();
let calendars = CalendarApp.getAllCalendars();
calendars.forEach(cal => {
ScriptApp.newTrigger("calendarUpdate").forUserCalendar(cal.id).onEventUpdated().create();
});
}
/* This trigger will provide us the calendar ID from which the event was fired, then you can perform your CalendarApp and sheet operation. If you want to synchronize new update more efficiently then use Calendar Advance Service, which will provide you with synchronization token that you can use to retrieve only updated and added events in calendar.
*/
function calendarUpdate(e) {
logSyncedEvents(e.calendarId);
}
function clearAllTriggers() {
let triggers = ScriptApp.getProjectTriggers();
triggers.forEach(trigger => {
if (trigger.getEventType() == ScriptApp.EventType.ON_EVENT_UPDATED) ScriptApp.deleteTrigger(trigger);
});
}

How do I delete individual responses in FormApp

Shouldn't FormResponse have a remove or delete response method?
https://developers.google.com/apps-script/reference/forms/form-response
Is it there and I'm just missing it in the docs?
I'm talking about Responses here not Items.
Nope, not there. And no external API to fill the gap.
So here's a crazy idea.
You could write a script that gets all the responses, calls deleteAllResponses(), then writes back all but the one(s) you want deleted. You'd then have summary info that reflects the responses you care about, but you'd have lost the submission dates (...which you could add as non-form data in a spreadsheet), and submitter UserID (in Apps Domains only, and again you could retain it outside the form).
Whether or not the compromises are acceptable depend on what your intended use of the form data is.
Code
This is a forms-contained script with a simple menu & UI, which will delete indicated responses. The content of deleted responses are logged.
/**
* Adds a custom menu to the active form, containing a single menu item for
* invoking deleteResponsesUI() specified below.
*/
function onOpen() {
FormApp.getUi()
.createMenu('My Menu')
.addItem('Delete response(s)', 'deleteResponsesUI')
.addToUi();
}
/**
* UI for Forms function, deleteResponses().
*/
function deleteResponsesUI() {
var ui = FormApp.getUi();
var response = ui.prompt("Delete Response(s)",
"List of resonse #s to delete, separated by commas",
ui.ButtonSet.OK_CANCEL);
if (response.getSelectedButton() == ui.Button.OK) {
var deleteCSV = response.getResponseText();
var numDeleted = deleteResponses(deleteCSV.split(/ *, */));
ui.alert("Deleted "+numDeleted+" responses.", ui.ButtonSet.OK);
}
}
/**
* Deletes the indicated response(s) from the form.
* CAVEAT: Timestamps for all remaining responses will be changed.
* Deleted responses are logged, but cannot be recovered.
*
* #parameter {Number or Number[]} Reponse(s) to be deleted, 0-indexed.
*
* #returns {Number} Number of responses that were deleted.
*/
function deleteResponses(trash) {
if (!trash) throw new Error( "Missing parameter(s)" );
Logger.log(JSON.stringify(trash))
if (!Array.isArray(trash)) trash = [trash]; // If we didn't get an array, fix it
var form = FormApp.getActiveForm();
var responses = form.getResponses();
// Really feels like we should ask "ARE YOU REALLY, REALLY SURE?"
form.deleteAllResponses();
var numDeleted = 0;
for (var i = 0; i < responses.length; i++) {
if ( trash.indexOf(i.toString()) !== -1 ) {
// This response to be deleted
Logger.log( "Deleted response: " + JSON.stringify(itemizeResponse(responses[i] )) )
numDeleted++
}
else {
// This response to be kept
var newResponse = form.createResponse();
var itemResponses = responses[i].getItemResponses();
for (var j = 0; j < itemResponses.length; j++) {
newResponse.withItemResponse(itemResponses[j]);
}
newResponse.submit();
}
}
return numDeleted
}
/**
* Returns item responses as a javascript object (name/value pairs).
*
* #param {Response} Form Response object
*
* #returns Simple object with all item responses + timestamp
*/
function itemizeResponse(response) {
if (!response) throw new Error( "Missing parameter(s)" );
var itemResponses = response.getItemResponses();
var itemizedResponse = {"Timestamp":response.getTimestamp()};
for (var j = 0; j < itemResponses.length; j++) {
itemizedResponse[itemResponses[j].getItem().getTitle()] = itemResponses[j].getResponse();
}
return itemizedResponse;
}
#james-ferreira
New Google Forms allows you to delete even individual responses from within a Google Form itself without the need of a script.
The answer that this wasn't possible by #mogsdad and #john was true until very recently.
--This is now possible on the New Google Forms--
Google announcement on the 10th of February 2016. (New Google Forms is now the default option)
Delete ALL of the responses:
Delete individual responses:
To delete individual responses you click on the "Responses" tab and choose "Individual". You locate the record you wish to delete and click on the trash can icon to delete that individual response.
Make a note however that the response/s will NOT be deleted from the connected to the form spreadsheet.
It is now possible by script:
To delete a response you need the id of the response you wish to delete:
FormApp.getActiveForm().deleteResponse(responseId)
note: you may also get the form with openById
FormApp.openById(form_id).deleteResponse(responseId)
ref:
https://developers.google.com/apps-script/reference/forms/form#deleteresponseresponseid
note: the response is permanently removed from both the summary and individual responses.
You can delete all responses from a form using deleteAllResponses(), but not individual responses. You can't even delete individual responses manually. If your form responses are directed to a spreadsheet, you use the Spreadsheet Service to select and delete individual responses there.