How to detect user changing sheet? - google-apps-script

How to detect and trigger custom action during opening specific sheet in spreadsheet?
I could not find proper function in https://developers.google.com/apps-script/guides/triggers/events

To do polling as Mogsdad suggested you could use this code in your Sidebar (or any UI element)
$(function() {
/**
* On document load, assign click handlers to each button and try to load the
* user's origin and destination language preferences if previously set.
*/
poll();
}
function poll(interval){
interval = interval || 2000;
setTimeout(function(){
google.script.run.withSuccessHandler(loadSheetName)
.withFailureHandler(showError).getSheetName();
poll();
}, interval);
};
function loadSheetName(sheetName) {
alert(sheetName);
}
function getSheetName() {
google.script.run.withSuccessHandler(loadSheetName)
.withFailureHandler(showError).getSheetName();
}
And in your .gs code
function getSheetName() {
var sheetName = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet().getName();
return sheetName;
}

There is no API provided to trigger on user events, aside from the ones in the documentation you've linked.
I can think of only one work-around: If your application involves having a custom UI element, specifically a sidebar or dialog, then you could poll for the active cell, and determine the active sheet from that.
See How to poll a Google Doc from an add-on for a start.

Related

Google Docs Apps Script Loading Indicator

I'm running a script when I open/refresh a Google Doc. It takes some time to run and can affect values in the doc, so I want to show some type of loading indicator to the user until it's done. I would prefer to not overlay a modal, so the doc is still accessible, but apart from that any solution, even a hacky one, would be ok.
// EXECUTE SCRIPT ON OPEN
function onOpen() {
// SHOW LOADING TO USER
initializeDoc();
doSomeStuffThatTakesSomeTime();
const ui = DocumentApp.getUi();
ui.createMenu('MyMenu')
.addItem('Click', 'doSomeStuffThatTakesSomeTime')
.addToUi();
// TURN LOADING OFF
}
Loading...
function loading() {
const ui = DocumentApp.getUi();
ui.showModelessDialog(HtmlService.createHtmlOutput("Loading...."),"Please Wait");
Utilities.sleep(5000);
const hl = '<!DOCTYPE html><html><head><base target="_top"></head><script>window.onload=()=>{google.script.host.close();}</script><body></body></html>';
ui.showModelessDialog(HtmlService.createHtmlOutput(hl),"Good Bye")
}
You will require an installable onOpen() such as below:
function onMyOpen(e) {
loading();
}

time based trigger not working for add-on users

I have an Google sheet add-on that uses a clock based trigger to make an api call to Google My Business API with a function called uploadPosts().
The User can set the trigger via the addon's menu like this:
function createTrigger() {
try{
deleteTriggers();
let ss = SpreadsheetApp.getActiveSpreadsheet();
let configTab = ss.getSheetByName('CONFIG');
let hour = configTab.getRange('A3').getValue();
let hourVal = hour.toString().split(" - ")[0].trim();
ScriptApp.newTrigger('uploadPosts')
.timeBased()
.atHour(hourVal)
.nearMinute(0)
.everyDays(1)
.create();
}
catch(err) {
console.log(err);
if (err.message === "Cannot read property 'getRange' of null"){
Browser.msgBox("😩 Woa there! You must run Initial Setup, before turning on Auto-Posting! 😩 ");
}
}
}
function deleteTriggers () {
try{
var triggers = ScriptApp.getProjectTriggers();
for (var i = 0; i < triggers.length; i++) {
ScriptApp.deleteTrigger(triggers[i]);
}
}
catch(err) {
if (err.message === "Cannot read property 'getRange' of null"){
Browser.msgBox("😩 Woa there! You must run Initial Setup, before turning off Auto-Posting! 😩 ");
}
}
}
The triggers work fine for anyone who is an editor of the Add-on, but does not set a functioning trigger for add-on users. When they go to https://script.google.com/home/triggers they see a row with a "-" set for both the project name and function name.
What do I need to change in order for a clock based trigger to work for add-on users?
The solution to this issue was twofold.
The project used the newer V8 runtime. There were tons of issues in the apps script google groups re: the new runtime and time based triggers for add-ons.
I Modified all functions to revert to es5 syntax and then reverted to the old runtime.
I needed to add a sensitive scope: https://www.googleapis.com/auth/script.scriptapp
After I got approved by Google and reverting runtimes, my triggers worked as anticipated.

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);
});
}

Folders.hasNext() returning false from Google Sheets but true when called directly from Google Script

Overview
Im making a script for my google sheet.
I have a helper method which takes email for parameter (string) and then invites that email to google drives folder.
I also have edit() function which gets called whenever a field in Google Sheets is changed.
Problem:
When helper method is called by itself from Google Scripts it works fine and I get an invite.
When helper method is triggered from edit() while im in google sheets, it doesn't go all the way through to send an invitation. It stops at while function which returns false:
var newFolder = DriveApp.getFoldersByName("New Folder");
Logger.log(newFolder.hasNext());
while(newFolder.hasNext()) { -> returns false while in google sheets
var folder = newFolder.next();
Logger.log("folder -> " + folder);
folder.addViewer(newPersonEmail);
}
What I tried:
Logger.log shows that newFolder.hasNext() returns false while script
gets called from edit() function (when im making a change in google
sheets). But it returns true when I simply debug function in Google
scripts.
You have to register edit() for the specific user. It's not possible for the simple onEdit trigger.
It returns false because there is nothing for the current user. And it's true.
===== Updated =====
As for me, I think It's normal to create a triggers for users every time when we needs their scopes. Suppose we need to force a user to register a trigger. He has to run regTrigger()
/**
* #param {GoogleAppsScript.Events.SheetsOnEdit} e
*/
function edit(e) {
// there is the edit eveng action
}
/**
* Register trigger
* #returns {GoogleAppsScript.Script.Trigger}
*/
function regTrigger() {
var activeSpreadsheet = SpreadsheetApp.getActive();
var triggers = ScriptApp.getUserTriggers(activeSpreadsheet).filter(function(
trigger
) {
return (
trigger.getEventType() === ScriptApp.EventType.ON_EDIT &&
trigger.getHandlerFunction() === 'edit'
);
});
if (triggers.length) return triggers[0];
return ScriptApp.newTrigger('edit')
.forSpreadsheet(activeSpreadsheet)
.onEdit()
.create();
}
Now your task is to give the user the opportunity to perform this function once (regTrigger). For example, from the menu. Or make it happen when you open the Table forcing some events.

Get the ID of a spreadsheet

So, I'm already trying this for a week, still errors. Can get the spreadsheet ID properly.
Currently I have this code:
function getSS(e,getSS) {
//If not yet authorized - get current spreadsheet
if (e && e.authMode != ScriptApp.AuthMode.FULL) {
var getSS = SpreadsheetApp.getActiveSpreadsheet();
}
//Else if authorized set/get property to open spreadsheet by ID for time-driven triggers
else {
if(!PropertiesService.getDocumentProperties().getProperty('SOURCE_DATA_ID')){
PropertiesService.getDocumentProperties().setProperty('SOURCE_DATA_ID', e.source.getId());
}
var getSSid = PropertiesService.getDocumentProperties().getProperty('SOURCE_DATA_ID');
var getSS = SpreadsheetApp.openById(getSSid);
}
return getSS;
}
var SS = getSS();
It's supposed to get active spreadsheet ID when the addon is not yet authorized, and get a spreadsheet ID from properties when it's authorized. However, when testing as installed, I always get an error that I don't have permission to use openById() or getDocumentProperties()
How do I keep SS as global variable without it being null in any authMode?
Note that global variables are constructed each and every time that Apps Script project is loaded / used. Also note that no parameters are passed to functions automatically - you have to designate a function as either a simple trigger (special function name) or an installed trigger before Google will send it an argument, and in all other cases you have to explicitly specify the argument.
The problem is then that you declare var SS = getSS(); in global scope, and do not pass it any parameters (there are no parameters you could pass it, either). Thus in the definition of getSS(), even if you have it as function getSS(e) {, there is no input argument to bind to the variable e.
Therefore this criteria if (e && ...) is always false, because e is undefined, which means your else branch is always executed. In your else branch, you assume that you have permissions, and your test never was able to even try to check that. Hence, your errors. You might have meant to write if (!e || e.authMode !== ScriptApp.AuthMode.FULL) which is true if either of the criteria is true. Consider reviewing JavaScript Logical Operators.
While you don't share how your code uses this spreadsheet, I'm quite certain it doesn't need to be available as an evaluated global. Any place you use your SS variable, you could have simply used SpreadsheetApp.getActiveSpreadsheet() instead.
Your getSS() function additionally force the use of a permissive scope by using openById - you cannot use the preferred ...spreadsheets.currentonly scope.
Example add-on code:
function onInstall(e) {
const wb = e.source;
const docProps = PropertiesService.getDocumentProperties();
docProps.setProperty('SOURCE_DATA_ID', wb.getId());
/**
* set other document properties, create triggers, etc.
*/
// Call the normal open event handler with elevated permissions.
onOpen(e);
}
function onOpen(e) {
if (!e || e.authMode === ScriptApp.AuthMode.NONE) {
// No event object, or we have no permissions.
} else {
// We have both an event object and either LIMITED or FULL AuthMode.
}
}
Consider reviewing the Apps Script guide to add-on authorization and setup: https://developers.google.com/apps-script/add-ons/lifecycle
So I made it this way:
//Because onInstall() only runs once and user might want to launch addon in different spreadsheets I moved getting ID to onOpen(),
function onInstall (e) {
getAuthUrl();
onOpen(e);
}
Cases for different AuthModes.
function onOpen(e) {
var menu = SpreadsheetApp.getUi().createAddonMenu();
if (e && e.authMode === ScriptApp.AuthMode.NONE) {
menu.addItem('Authorize this add-on', 'auth');
}
else {
//If addon is authorized add menu with functions that required it. Also get the id of the current spreadsheet and save it into properties, for use in other functions.
menu.addItem('Run', 'run');
var ssid = SpreadsheetApp.getActive().getId();
var docProps = PropertiesService.getDocumentProperties();
docProps.setProperty('SOURCE_DATA_ID', ssid);
}
menu.addToUi();
}
Function that pops authorization window:
function getAuthUrl() {
var authInfo,msg;
authInfo = ScriptApp.getAuthorizationInfo(ScriptApp.AuthMode.FULL);
msg = 'This addon needs authorization to work properly on this spreadsheet. Click ' +
'this url to authorize: <br><br>' +
'<a href="' + authInfo.getAuthorizationUrl() +
'" style="cursor:pointer;padding:5px;background: #4285f4;border:1px #000;text-align: center;margin-top: 15px;width: calc(100% - 10px);font-weight: 600;color: #fff">AUTHORIZE</a>' +
'<br><br> This spreadsheet needs to either ' +
'be authorized or re-authorized.';
//return msg;//Use this for testing
//ScriptApp.AuthMode.FULL is the auth mode to check for since no other authorization mode requires
//that users grant authorization
if (authInfo.getAuthorizationStatus() === ScriptApp.AuthorizationStatus.REQUIRED) {
return msg;
} else {
return "No Authorization needed";
};
console.info('Authorization window called');
}