I'm new to coding and recently I've created a Google script (based on two other scripts) which does the following:
Searches for a Gmail draft by its subject line
Gets the Gmail draft and uses it as a template to create multiple drafts with unique attachments
Puts a confirmation phrase after drafts are created.
Here is the code:
//Change these to match the column names you are using for email recepient addresses and merge status column//
var RECIPIENT_COL = "Email";
var MERGE_STATUS_COL = "M";
//Creates the menu item "Mail Merge" for user to run scripts on drop-down//
function onOpen(e) {
var ui = SpreadsheetApp.getUi();
ui.createMenu('Mail Merge')
.addItem('๐ Create Drafts', 'createDrafts').addToUi();
}
function createDrafts() {
// search for the draft Gmail message to merge with by its subject line
var subjectLine = Browser.inputBox("Select draft " + "to merge with:", "Paste the subject line:", Browser.Buttons.OK_CANCEL);
if (subjectLine === "cancel" || subjectLine == ""){
// if no subject line finish up
return;
}
// get the draft Gmail message to use as a template
var emailTemplate = getGmailTemplateFromDrafts_(subjectLine);
emailTemplate.subject = subjectLine;
// get the data from the active sheet
var sheet = SpreadsheetApp.getActiveSheet();
var dataRange = sheet.getDataRange();
// fetch values for each row in the Range.
var data = dataRange.getValues();
// assuming row 1 contains our column headings
var header = data.shift();
// get the index of column named 'M' (Assume header names are unique)
var draftCreatedColIdx = header.indexOf(MERGE_STATUS_COL);
var object = data.map(function(row) {
// create a new object for next row using the header as a key
var nextRowObject = header.reduce(function(accumulator, currentValue, currentIndex) {
accumulator[currentValue] = row[currentIndex];
return accumulator;
}, {}) // Use {} here rather than initialAccumulatorValue
return nextRowObject;
});
// loop through all the rows of data
object.forEach(function(row, rowIdx){
// only create drafts if mail merge status cell is blank
if (row[MERGE_STATUS_COL] === ''){
var msgObj = fillInTemplateFromObject_(emailTemplate, row);
var attachment_id = "File Name";
// split the values taken from cell into array
var pdfName = row[attachment_id].split(', ');
// initialize files as empty array
var files = [];
// run through cell values and perform search
for(var j in pdfName){
// perform the search,results is a FileIterator
var results = DriveApp.getFilesByName(pdfName[j]);
// interate through files found and add to attachment results
while(results.hasNext()) {
// add files to array
files.push(results.next());
}
}
// #see https://developers.google.com/apps-script/reference/gmail/gmail-app#sendemailrecipient-subject-body-options
GmailApp.createDraft(row[RECIPIENT_COL], msgObj.subject, msgObj.text, {htmlBody: msgObj.html, attachments: files});
// create a confirmation phrase in the first column
sheet.getRange("A" + (rowIdx + 2)).setValue("DRAFT");
}
});
}
/**
* Get a Gmail draft message by matching the subject line.
* #param {string} subject_line to search for draft message
* #return {object} containing the plain and html message body
*/
function getGmailTemplateFromDrafts_(subject_line) {
try {
// get drafts
var drafts = GmailApp.getDrafts();
// filter the drafts that match subject line
var draft = drafts.filter(subjectFilter_(subject_line))[0];
// get the message object
var msg = draft.getMessage();
return {text: msg.getPlainBody(), html:msg.getBody()};
} catch(e) {
throw new Error("Oops - can't find Gmail draft");
}
}
/**
* Filter draft objects with the matching subject linemessage by matching the subject line.
* #param {string} subject_line to search for draft message
* #return {object} GmailDraft object
*/
function subjectFilter_(subject_line){
return function(element) {
if (element.getMessage().getSubject() === subject_line) {
return element;
}
}
}
/**
* Fill HTML string with data object.
* #param {string} template string containing {{}} markers which are replaced with data
* #param {object} data object used to replace {{}} markers
* #return {object} message replaced with data
* H/T https://developers.google.com/apps-script/articles/mail_merge
*/
function fillInTemplateFromObject_(template, data) {
// convert object to string for simple find and replace
template = JSON.stringify(template);
// Search for all the variables to be replaced, for instance {{Column name}}
var templateVars = template.match(/{{([^}]+)}}/g);
// Replace variables from the template with the actual values from the data object.
// If no value is available, replace with the empty string.
for (var i = 0; i < templateVars.length; ++i) {
// strip out {{ }}
var variableData = data[templateVars[i].substring(2, templateVars[i].length - 2)];
template = template.replace(templateVars[i], variableData || "");
}
// convert back to object
return JSON.parse(template);
}
The script works as expected but when I'm trying to process too many rows with too many attachments it exceeds a 6-minute Google Script maximum execution time.
While trying to solve this problem I found a simple script that uses a continuationToken and by doing so never exceeds the limit. My goal is to try to use the same principle in my own script and to process rows by tens. Unfortunatelly, I haven't had any luck so far and need some help. Here's the code of the script that I found:
Code.gs
function onOpen() {
SpreadsheetApp.getUi().createMenu("List Drive files").addItem('Start', 'start').addToUi();
}
function start() {
var ui = HtmlService.createHtmlOutputFromFile('ui');
return SpreadsheetApp.getUi().showSidebar(ui);
}
function getDriveFiles(continuationToken) {
if(continuationToken) {
var files = DriveApp.continueFileIterator(continuationToken);
}
else {
var files = DriveApp.getFiles();
}
var i = 0;
while (files.hasNext() && i < 10) {
var file = files.next();
SpreadsheetApp.getActiveSheet().appendRow([file.getName(), file.getUrl()]);
i++;
if(i == 10) {
return files.getContinuationToken();
}
}
}
ui.html
<link rel="stylesheet" href="https://ssl.gstatic.com/docs/script/css/add-ons.css">
<div style="text-align:center; margin-top:10px">
<div>Files processed:</div>
<div id="nbOfFilesProcessed">0</div>
<br>
<button id="startButton" class="blue" onclick="start()">Start</button>
<div class="secondary">Close the sidebar to stop the script.</div>
</div>
<script>
function start() {
document.getElementById("startButton").disabled = true;
google.script.run.withSuccessHandler(onSuccess).getDriveFiles();
}
function onSuccess(continuationToken){
// If server function returned a continuationToken it means the task is not complete
// so ask the server to process a new batch.
if(continuationToken) {
var nbOfFilesProcessedEl = document.getElementById("nbOfFilesProcessed");
nbOfFilesProcessedEl.innerHTML = parseInt(nbOfFilesProcessedEl.innerHTML) + 10;
google.script.run.withSuccessHandler(onSuccess).getDriveFiles(continuationToken);
}
}
</script>
From what I see in the code you posted you will have to edit your createDrafts function in this way:
Edit how the function is triggered: you will have to use an HTML ui element to run javascript inside it.
Edit the while loop so that it has a return statement when you hit the limit of your batch.
Create a Javascript function in the HTML ui element that handles the success of the createDrafts function and recursively calls it in case that the continuationToken is returned.
Snippets
UI Component
You can keep your custom menu and on click add this HTML to a UI dialog.
- code.gs -
//Creates the menu item "Mail Merge" for user to run scripts on drop-down//
function onOpen(e) {
var ui = SpreadsheetApp.getUi();
ui.createMenu('Mail Merge')
.addItem('๐ Create Drafts', 'openDialog').addToUi();
}
function openDialog() {
// Display a modal dialog box with custom HtmlService content.
var htmlOutput = HtmlService
.createHtmlOutputFromFile('Dialog')
.setWidth(250)
.setHeight(300);
SpreadsheetApp.getUi().showModalDialog(htmlOutput, 'Create Drafts');
}
- Dialog.html -
<!-- The UI will be very similar to the one you found, I will keep only the strictly necessary statements for this example -->
<div>
<button id="startButton" onclick="startBatch()">Start</button>
</div>
<script>
function startBatch() {
google.script.run.withSuccessHandler(onSuccess).createDrafts();
}
function onSuccess(continuationToken){
// If server function returned a continuationToken it means the task is not complete
// so ask the server to process a new batch.
if(continuationToken) {
google.script.run.withSuccessHandler(onSuccess).createDrafts(continuationToken);
}
}
</script>
Apps Script Component
function createDrafts(continuationToken) {
var batchLimit = 10;
// ...
// run through cell values and perform search
for(var j in pdfName){
// perform the search,results is a FileIterator
if (continuationToken) {
var results = DriveApp.continueFileIterator(continuationToken);
} else {
var results = DriveApp.getFilesByName(pdfName[j]);
}
// interate through files found and add to attachment results
let i = 0;
while(results.hasNext() && i<batchLimit) {
// add files to array
files.push(results.next());
i++;
if (i === batchLimit) {
return results.getContinuationToken();
}
}
}
Final considerations
As an improvement to your batch operation, I would save all the user inputs so that you will be able to continue the script without prompting for it again. You can either pass these values to the return function on a javascript object or save them in the cache with the CacheService utility.
Moreover, try to find the correct trade off between execution time and batch limit: A small batch limit will never hit the time limit but will consume your quota very fast.
References:
Client Side API
Cache Service
Apps Script UI
Related
I'm trying to use this Web Push walk-through to allow my customers to get push notifications sent to their phones/desktops: https://framework.realtime.co/demo/web-push/
The demo on the site is working for me, and when I copy it over to my server I'm able to push messages down and I see them being logged in the JavaScript console by my service worker with every push down the channel.
However, only the FIRST message pushed down the channel is causing a notification to appear, the rest simply don't show up. If I revoke the service-worker and reload the page (to get a new one) it works again -- for 1 push.
I'm using the same ortc.js file they are, an almost identical service-worker.js, modified with the ability to pass JSON for image/URL options. My modified service worker code is below.
I'm not getting any errors in the JS console (the 2 in the image above were from something else), but I am getting a red x icon next to the service worker, though the number next to it doesn't seem to be tied to anything I can tell (and clicking it does nothing; clicking the service-worker.js side just drops me to line 1 of the service-worker.js file, below.
My question is: why am I getting the first notification, but not any others? Or how can I go about debugging it? My JS console is showing the payloads, and stepping through the JS with breakpoints has me getting lost in the minified firebase code (I have tried both 3.5 and 6.5 for the firebase.js files).
Here is my service worker:
// Give the service worker access to Firebase Messaging.
// Note that you can only use Firebase Messaging here, other Firebase libraries
// are not available in the service worker.
importScripts('https://www.gstatic.com/firebasejs/3.5.0/firebase-app.js');
importScripts('https://www.gstatic.com/firebasejs/3.5.0/firebase-messaging.js');
// Initialize the Firebase app in the service worker by passing in the
// messagingSenderId.
firebase.initializeApp({
'messagingSenderId': '580405122074'
});
// Retrieve an instance of Firebase Messaging so that it can handle background
// messages.
const fb_messaging = firebase.messaging();
// Buffer to save multipart messages
var messagesBuffer = {};
// Gets the number of keys in a dictionary
var countKeys = function (dic) {
var count = 0;
for (var i in dic) {
count++;
}
return count;
};
// Parses the Realtime messages using multipart format
var parseRealtimeMessage = function (message) {
// Multi part
var regexPattern = /^(\w[^_]*)_{1}(\d*)-{1}(\d*)_{1}([\s\S.]*)$/;
var match = regexPattern.exec(message);
var messageId = null;
var messageCurrentPart = 1;
var messageTotalPart = 1;
var lastPart = false;
if (match && match.length > 0) {
if (match[1]) {
messageId = match[1];
}
if (match[2]) {
messageCurrentPart = match[2];
}
if (match[3]) {
messageTotalPart = match[3];
}
if (match[4]) {
message = match[4];
}
}
if (messageId) {
if (!messagesBuffer[messageId]) {
messagesBuffer[messageId] = {};
}
messagesBuffer[messageId][messageCurrentPart] = message;
if (countKeys(messagesBuffer[messageId]) == messageTotalPart) {
lastPart = true;
}
}
else {
lastPart = true;
}
if (lastPart) {
if (messageId) {
message = "";
// Aggregate all parts
for (var i = 1; i <= messageTotalPart; i++) {
message += messagesBuffer[messageId][i];
delete messagesBuffer[messageId][i];
}
delete messagesBuffer[messageId];
}
return message;
} else {
// We don't have yet all parts, we need to wait ...
return null;
}
}
// Shows a notification
function showNotification(message, settings) {
// In this example we are assuming the message is a simple string
// containing the notification text. The target link of the notification
// click is fixed, but in your use case you could send a JSON message with
// a link property and use it in the click_url of the notification
// The notification title
var notificationTitle = 'Web Push Notification';
var title = "Company Name";
var icon = "/img/default.png";
var url = "https://www.example.com/";
var tag = "same";
if(settings != undefined) {
if(hasJsonStructure(settings)) settings = JSON.parse(settings);
title = settings.title;
icon = settings.icon;
url = settings.click_url;
tag = "same";
}
// The notification properties
const notificationOptions = {
body: message,
icon: icon,
data: {
click_url: url
},
tag: tag
};
return self.registration.showNotification(title,
notificationOptions);
}
// If you would like to customize notifications that are received in the
// background (Web app is closed or not in browser focus) then you should
// implement this optional method.
fb_messaging.setBackgroundMessageHandler(function(payload) {
console.log('Received background message ', payload);
// Customize notification here
if(payload.data && payload.data.M) {
var message = parseRealtimeMessage(payload.data.M);
return showNotification(message, payload.data.P);
}
});
// Forces a notification
self.addEventListener('message', function (evt) {
if(hasJsonStructure(evt.data)) {
var opts = JSON.parse(evt.data);
var message = opts.message;
evt.waitUntil(showNotification(message, opts));
}
else evt.waitUntil(showNotification(evt.data));
});
// The user has clicked on the notification ...
self.addEventListener('notificationclick', function(event) {
// Android doesnโt close the notification when you click on it
// See: http://crbug.com/463146
event.notification.close();
if(event.notification.data && event.notification.data.click_url) {
// gets the notitication click url
var click_url = event.notification.data.click_url;
// This looks to see if the current is already open and
// focuses if it is
event.waitUntil(clients.matchAll({
type: "window"
}).then(function(clientList) {
for (var i = 0; i < clientList.length; i++) {
var client = clientList[i];
if (client.url == click_url && 'focus' in client)
return client.focus();
}
if (clients.openWindow) {
var url = click_url;
return clients.openWindow(url);
}
}));
}
});
function hasJsonStructure(str) {
if (typeof str !== 'string') return false;
try {
const result = JSON.parse(str);
const type = Object.prototype.toString.call(result);
return type === '[object Object]'
|| type === '[object Array]';
} catch (err) {
return false;
}
}
I had a similar problem. I was using the tag property from the options object. I gave a fixed value instead of a unique value. So only the first notification was showing up. Then I read this:
tag: An ID for a given notification that allows you to find, replace,
or remove the notification using a script if necessary.
in the documentation and understand cause it needs to be a unique value. So now every notification is showing up. How I see also your tag variable is hardcodated.
I'm trying to submit a contact form using google-app-script. When I'm submit that, it displays the following page:
https://script.google.com/macros/s/AKfycbx-sxqjOmCD5pGhLzzcI9Tc906bZN5VnNQQCe34/exec
I want this url to to redirect to my another page, how can I achieve that using scripts.
Below is the script. Just for the info, I'm a beginner.
/******************************************************************************
* This tutorial is based on the work of Martin Hawksey twitter.com/mhawksey *
* But has been simplified and cleaned up to make it more beginner friendly *
* All credit still goes to Martin and any issues/complaints/questions to me. *
******************************************************************************/
// if you want to store your email server-side (hidden), uncomment the next line
var TO_ADDRESS = "abc#gmail.com";
// spit out all the keys/values from the form in HTML for email
// uses an array of keys if provided or the object to determine field order
function formatMailBody(obj, order) {
var result = "";
if (!order) {
order = Object.keys(obj);
}
// loop over all keys in the ordered form data
for (var idx in order) {
var key = order[idx];
result += "<h4 style='text-transform: capitalize; margin-bottom: 0'>" + key + "</h4><div>" + sanitizeInput(obj[key]) + "</div>";
// for every key, concatenate an `<h4 />`/`<div />` pairing of the key name and its value,
// and append it to the `result` string created at the start.
}
return result; // once the looping is done, `result` will be one long string to put in the email body
}
// sanitize content from the user - trust no one
// ref: https://developers.google.com/apps-script/reference/html/html-output#appendUntrusted(String)
function sanitizeInput(rawInput) {
var placeholder = HtmlService.createHtmlOutput(" ");
placeholder.appendUntrusted(rawInput);
return placeholder.getContent();
}
function doPost(e) {
try {
Logger.log(e); // the Google Script version of console.log see: Class Logger
record_data(e);
// shorter name for form data
var mailData = e.parameters;
// names and order of form elements (if set)
var orderParameter = e.parameters.formDataNameOrder;
var dataOrder;
if (orderParameter) {
dataOrder = JSON.parse(orderParameter);
}
// determine recepient of the email
// if you have your email uncommented above, it uses that `TO_ADDRESS`
// otherwise, it defaults to the email provided by the form's data attribute
var sendEmailTo = (typeof TO_ADDRESS !== "undefined") ? TO_ADDRESS : mailData.formGoogleSendEmail;
// send email if to address is set
if (sendEmailTo) {
MailApp.sendEmail({
to: String(sendEmailTo),
subject: "About Me: Contact form submitted",
// replyTo: String(mailData.email), // This is optional and reliant on your form actually collecting a field named `email`
htmlBody: formatMailBody(mailData, dataOrder)
});
}
return ContentService // return json success results
.createTextOutput(
JSON.stringify({"result":"success",
"data": JSON.stringify(e.parameters) }))
.setMimeType(ContentService.MimeType.JSON);
} catch(error) { // if error return this
Logger.log(error);
return ContentService
.createTextOutput(JSON.stringify({"result":"error", "error": error}))
.setMimeType(ContentService.MimeType.JSON);
}
}
/**
* record_data inserts the data received from the html form submission
* e is the data received from the POST
*/
function record_data(e) {
var lock = LockService.getDocumentLock();
lock.waitLock(30000); // hold off up to 30 sec to avoid concurrent writing
try {
Logger.log(JSON.stringify(e)); // log the POST data in case we need to debug it
// select the 'responses' sheet by default
var doc = SpreadsheetApp.getActiveSpreadsheet();
var sheetName = e.parameters.formGoogleSheetName || "responses";
var sheet = doc.getSheetByName(sheetName);
var oldHeader = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
var newHeader = oldHeader.slice();
var fieldsFromForm = getDataColumns(e.parameters);
var row = [new Date()]; // first element in the row should always be a timestamp
// loop through the header columns
for (var i = 1; i < oldHeader.length; i++) { // start at 1 to avoid Timestamp column
var field = oldHeader[i];
var output = getFieldFromData(field, e.parameters);
row.push(output);
// mark as stored by removing from form fields
var formIndex = fieldsFromForm.indexOf(field);
if (formIndex > -1) {
fieldsFromForm.splice(formIndex, 1);
}
}
// set any new fields in our form
for (var i = 0; i < fieldsFromForm.length; i++) {
var field = fieldsFromForm[i];
var output = getFieldFromData(field, e.parameters);
row.push(output);
newHeader.push(field);
}
// more efficient to set values as [][] array than individually
var nextRow = sheet.getLastRow() + 1; // get next row
sheet.getRange(nextRow, 1, 1, row.length).setValues([row]);
// update header row with any new data
if (newHeader.length > oldHeader.length) {
sheet.getRange(1, 1, 1, newHeader.length).setValues([newHeader]);
}
}
catch(error) {
Logger.log(error);
}
finally {
lock.releaseLock();
return;
}
}
function getDataColumns(data) {
return Object.keys(data).filter(function(column) {
return !(column === 'formDataNameOrder' || column === 'formGoogleSheetName' || column === 'formGoogleSendEmail' || column === 'honeypot');
});
}
function getFieldFromData(field, data) {
var values = data[field] || '';
var output = values.join ? values.join(', ') : values;
return output;
}
I tried to see your script, but it return this error: Script function not found: doGet
However, you could try do this...
window.location.href = "YOUR_URL";
In Google Apps Script, I'm unit-testing an app that I'm working on, using QUnit, using test-driven-development.
The code under test
I am, right now, working on fully testing, and then developing, the following function:
/**
*
* Creates a Sheet from a long string of data
* #param { string } data : the long string of data
* #returns { Sheet } the newly-created Sheet
**/
function createSheetFrom(data) {
// if data is not in the form of a string, we've got a problem
if (data !== data.toString())
throw TypeError("Data must be in the form of a string")
// parse the data into SheetData
sheetData = SheetData.parse(data);
// create a new Sheet
newSheet = SPREADSHEET.insertSheet(sheetData.projectName)
// create title rows for it
createTitleRow(sheet)
return newSheet
}
where SPREADSHEET is the Spreadsheet the script is attached to, saved as a global variable for convenience.
That function depends on, among a few others, the one right below it, which has been successfully fully tested and developed:
/**
* Creates a title row for the spreadsheet
*
* #param { Sheet } sheet : the spreadsheet to create a title row for
* #returns { Sheet } the sheet for chaining purposes
**/
function createTitleRow(sheet) {
// declare the titles
var titles = ["File", "Total Cyclomatic Complexity", "Already unit-tested?", "Status Date"];
// insert a row right at the beginning
sheet.insertRows(1);
var titleRow = sheet.getRange(1, 1, 1, titles.length);
// set some values on titleRow
titleRow.setValues(titles);
titleRow.setHorizontalAlignment(CENTER)
return sheet;
}
The tests:
My test against createSheetFrom is thus:
function testCreateSheetFrom() {
var globalSpreadsheet // for storing the state of SPREADSHEET
var sheet // a stub Sheet
var createTitleRowCalls = []
var titleRowFunction
QUnit.test("testing createSheetFrom with 'file contents' that is simply one line of output",
function() {
throws(function() {
val = createSheetFrom("3 blah funcName c:/blah/foo/bar");
}, "Exception caught successfully")
})
}
...and the one against createTitleRow is thus:
function testCreateTitleRow() {
var sheet // spy for the Sheet object from the Google Spreadsheet API
QUnit.testStart(function() {
// create a sheet spy
sheet = {
insertRowsCalls : [],
insertRows : function(idx) {
Logger.log("insertRows called")
this.insertRowsCalls.push({ args : idx });
},
getRangeCalls : [],
range : {
setValuesCalls : [],
setValues : function(arr) {
if (!Array.isArray(arr)) return;
// simply record the args that this was called with
this.setValuesCalls.push({ args : arr });
},
setHorizontalAlignment: function(setting) {}
},
// method stub
getRange : function(r0,c0,r1,c1) {
Logger.log('getRange called')
this.getRangeCalls.push({ args : Array.prototype.splice.call(arguments, 0) });
return this.range;
}
};
})
QUnit.test("testing createTitleRow",
function() {
// hit the method under test
val = createTitleRow(sheet);
// the methods better have been hit
equal(sheet.insertRowsCalls.length, 1, "sheet.insertRows only got invoked once")
ok(sheet.getRangeCalls, "sheet.getRange() got invoked at least once")
ok(sheet.getRangeCalls.length)
deepEqual(sheet.getRangeCalls[0].args.filter(function(val, key) { return key < 3 }), [1,1,1], "the right arguments got sent to sheet.getRange()")
setValuesCalls = sheet.range.setValuesCalls
ok(setValuesCalls.length, "A call was made to sheet.range.setValues")
equal(setValuesCalls[0].args.length, 4, "The call was made with four args, as we expect")
// sheet better have been returned
equal(val, sheet, "createTitleRow() returns the sheet for testing, as promised")
})
}
The tests all pass:
However, when I add to the tests against createSheetFrom the following sanity test, stubbing out createTitleRow in the setup, reverting it back to its real self in the teardown, both the tests against createSheetFrom and createTitleRow break!
The code for that breaking test:
QUnit.testStart(function() {
// create a spy out of sheet
sheet = {
}
// replace SPREADSHEET with a spy
globalSpreadsheet = SPREADSHEET
SPREADSHEET = {
insertSheetCalls : [],
insertSheet : function(str) {
this.insertSheetCalls.push({ args: str })
return sheet
}
}
// stub out the dependencies
titleRowFunction = createTitleRow
createTitleRow = function(sheet) {
createTitleRowCalls.push({ args : sheet })
return sheet
}
})
QUnit.test("SanityTesting createSheetFrom",
function() {
projectName = "main"
complexity = 3
packageName = "main"
funcName = "SetContext"
filename = "main.go"
fileContents = createFileContents(projectName,
createLineOfTextFrom(complexity, packageName, funcName, "C:/Users/mwarren/Desktop/" + filename))
sheet = createSheetFrom(fileContents)
ok(SPREADSHEET.insertSheetCalls.length, "SPREADSHEET.insertSheet got called")
})
QUnit.testDone(function() {
// set SPREADSHEET back
SPREADSHEET = globalSpreadsheet
// set the dependencies back
createTitleRow = titleRowFunction
})
Screenshots of the test regressions:
I don't know why these regressions are happening, especially since I set back the changes I made in setup...
The states of the objects used are carrying over to other test cases in other test functions, despite me using testDone to set the states of the objects back. This is now happening in other test cases, too
I'm trying to load data from multiple spreadsheets(~100) into a single spreadsheet, however when I try to do this my script times out. It appears that opening each spreadsheet takes a long time. Is there any way I can speed this up or a work around?
Here's what I use to open each spreadsheet
// We set the current spreadsheet to master and get the current date.
var master = SpreadsheetApp.getActive();
var masterSheet = master.getSheetByName('Master');
var users = master.getEditors();
var today = new Date();
// Adds the menu to the spreadsheet
function onOpen() {
var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
var entries = [{
name : "Update Data",
functionName : "retrievePartnerData"
}];
spreadsheet.addMenu("Submissions Menu", entries);
};
// First we get all data from the partner sheets
function retrievePartnerData() {
masterSheet.getRange(2, 1, masterSheet.getLastRow(), masterSheet.getLastColumn()).clear(); //Clear our master sheet aka Sheet All
masterSheet.hideSheet();
//Get's Promo Outline from the internal sheet and store it's values in the promoRange array
var promoRange = master.getSheetByName("Promotional Outline").getRange("A1:Z100").getValues();
var sheetPartnerArray = [];
// Row is an array that contaings the url's to the external spreadsheets
var row = master.getSheetByName('Partner Sheet Collection').getRange("B:B").getValues();
row.map(function(e){
if(e[0] != "" && e[0] != "Url"){
var ss = SpreadsheetApp.openByUrl(e[0]);
var studioName = ss.getSheets()[0].getRange("A1").getValue();
//Updates the Promotional Outline sheet in the partner sheet
var promoSheet = ss.getSheetByName("Promotional Outline");
promoSheet.getRange("A1:Z100").setValues(promoRange);
//Hide columns K to Z
promoSheet.hideColumns(11,4);
var sheet = ss.getSheets();
sheet.map(function(f){
var sheetName = f.getSheetName(); // Retrieves the sheetname of each sheet
var lastRow = 0;
if(f.getLastRow() == 1) {
lastRow = 1;
} else {
lastRow = f.getLastRow() - 1;
}
var dataRange = f.getRange(2, 1, lastRow, f.getLastColumn());
var data = dataRange.getValues();
for (var j = 0; j < data.length; j++) {
if (data[j][0].length != 0 && (data[j][5] > today || data[j][5] == "[Please Enter]")) { // We check if the promo end date is after the current day
var sheetRow = data[j];
sheetRow[1] = studioName;
sheetRow.unshift(sheetName); //Adds the Country to the beginning of the row using the sheet name from spreadsheets
sheetPartnerArray.push(sheetRow);
}
}
})
}
})
masterSheet.getRange(2, 1, sheetPartnerArray.length , sheetPartnerArray[0].length ).setValues(sheetPartnerArray);
};
Thanks!
One common approach is to set a trigger to restart your Big Job at some time in the future (just beyond the maximum execution time). Then your Big Job does as much as it can (or stops nicely at some logical point), and either gets killed or quietly exits. Either way, it gets restarted shortly after, and resumes its work.
Patt0 has taken this idea to an elegant end, providing a library that you can add to your script. With a few adaptations, you should be able to turn your retrievePartnerData() into a batch job.
Since you have a menu already, and retrievePartnerData() involves iterating over many spreadsheets, you have the opportunity to break the barrier another way, by completing each iteration (or better, a set of iterations) in a separate server script instance.
This technique appears in What happens when I "sleep" in GAS ? (execution time limit workaround)
And there is something similar in How to poll a Google Doc from an add-on. In that answer, a UI client uses a timer to repeatedly execute a server function. Here, though, iterations would be work-based, rather than time-based. This client-side function, running in your browser, would keep calling the server until there was no more work to be done:
/**
* Call the server-side 'serverProcess' function until there's no more work.
*/
function dispatchWork(){
if (window.runningProcess) {
}
google.script.run
.withSuccessHandler( //<<<< if last call was good
// After each interval, decide what to do next
function(workis) {
if (!workis.done) { //<<<<< check if we're done
// There's more work to do, keep going.
dispatchWork();
}
else {
// All done. Stop timer
stopTimer();
$('#start-process').hide();
$("#final").html(' <h2>Processing complete!</h2>');
}
})
.withFailureHandler(
function(msg, element) { //<<<<<< do this if error
showError(msg, $('#button-bar'));
element.disabled = false;
})
.serverProcess(); //<<<<< call server function
};
In your case, you first need to refactor retrievePartnerData() so it can be called from a client to process a single spreadsheet (or set of them). No doubt you have put considerable time into making that map loop work cleanly, and taking it apart will be painful, but it will be worth it.
The following spreadsheet-bound script can be adapted to your use. It consists of a menu item, a simple UI, and scripts on the client (Javascript + jQuery) and server (Google Apps Script), which control the work in intervals.
The control data is in a "SourceSheets" tab, and results will be copied to "Master".
Code.gs
var properties = PropertiesService.getScriptProperties();
// Adds the menu to the spreadsheet
function onOpen() {
var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
var entries = [{
name : "Update Data",
functionName : "updateData"
}];
spreadsheet.addMenu("Big Job", entries);
};
/**
* Presents UI to user.
*/
function updateData () {
var userInterface = HtmlService.createHtmlOutputFromFile("Conductor")
.setHeight(150)
.setWidth(250)
.setTitle("What 5 minute limit?");
var ss = SpreadsheetApp.getActiveSpreadsheet();
ss.show(userInterface)
}
/**
* Called from client, this function performs the server work in
* intervals. It will exit when processing time has exceeded MAX_INTERVAL,
* 3.5 minutes. Every time this function exits, the client is provided
* with the current status object, done=true when the work queue has
* been emptied.
*
* #returns {Object} Status { done: boolean }
*/
function serverProcess() {
var MAX_INTERVAL = (3.5 * 60); // minutes * seconds
var intervalStart = Math.round(new Date() / 1000);
// Get persisted work queue, if there is one
var queueProp = properties.getProperty('work-queue') || '[]';
var queue = JSON.parse(queueProp);
if (queue.length == 0) {
queue = prepareWork();
}
// Do the work for this interval, until we're out of time
while ((Math.round(new Date() / 1000) - intervalStart) < MAX_INTERVAL) {
if (queue.length > 0) {
var ssID = queue.shift();
processSheet(ssID);
properties.setProperty('work-queue', JSON.stringify(queue));
}
else break;
}
// Report result of this interval to client
var result = { done : (queue.length == 0) };
return( result );
}
/**
* Set up work queue & clear Master sheet, ready to import data from source sheets.
*
* #return {String[]} work queue
*/
function prepareWork() {
// No work yet, so set up work
var ss = SpreadsheetApp.getActive();
var masterSheet = ss.getSheetByName('Master');
var rowsToDelete = masterSheet.getMaxRows()-1;
if (rowsToDelete)
masterSheet.deleteRows(2, rowsToDelete); //Clear our master sheet aka Sheet All
// Build work queue
var queue = [];
var data = ss.getSheetByName('SourceSheets') // get all data
.getDataRange().getValues();
var headers = data.splice(0,1)[0]; // take headers off it
var ssIDcol = headers.indexOf('Spreadsheet ID'); // find column with work
for (var i=0; i<data.length; i++) {
queue.push(data[i][ssIDcol]); // queue up the work
}
// Persist the work queue as a scriptProperty
properties.setProperty('work-queue', JSON.stringify(queue));
return queue;
}
/**
* Do whatever work item we need. In this example, we'll import all data from
* the source sheet and append it to our Master.
*
* #param {String} ssID Source spreadsheet ID
*/
function processSheet(ssID) {
var masterSheet = SpreadsheetApp.getActive().getSheetByName('Master');
var sourceSheet = SpreadsheetApp.openById(ssID).getSheetByName('Sheet1');
Utilities.sleep(60000); // You probably don't want to do this... just wasting time.
var masterLastRow = masterSheet.getLastRow();
var sourceRows = sourceSheet.getLastRow();
masterSheet.insertRowsAfter(masterSheet.getLastRow(), sourceSheet.getLastRow());
var sourceData = sourceSheet.getDataRange().getValues().slice(1);
var destRange = masterSheet.getRange(masterLastRow+1, 1, sourceData.length, sourceData[0].length);
destRange.setValues(sourceData);
}
Conductor.html
<link rel="stylesheet" href="https://ssl.gstatic.com/docs/script/css/add-ons.css">
<!-- The CSS package above applies Google styling to buttons and other elements. -->
<div id="form-div" class="sidebar branding-below">
<span id="final"></span>
<form>
<div class="block" id="button-bar">
<button class="blue" id="start-process">Start processing</button>
</div>
</form>
</div>
<div class="bottom">
Elapsed processing time: <span id="elapsed">--:--:--</span>
</div>
<script src="//ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js">
</script>
<script>
/**
* On document load, assign click handlers to button(s), add
* elements that should start hidden (avoids "flashing"), and
* start polling for document selections.
*/
$(function() {
// assign click handler(s)
$('#start-process').click(startProcess);
});
/**
* Call the server-side 'serverProcess' function until there's no more work.
*/
function dispatchWork(){
if (window.runningProcess) {
}
google.script.run
.withSuccessHandler(
// After each interval, decide what to do next
function(workis) {
if (!workis.done) {
// There's more work to do, keep going.
dispatchWork();
}
else {
// All done. Stop timer
stopTimer();
$('#start-process').hide();
$("#final").html(' <h2>Processing complete!</h2>');
}
})
.withFailureHandler(
function(msg, element) {
showError(msg, $('#button-bar'));
element.disabled = false;
})
.serverProcess();
};
/**
* Runs a server-side function to retrieve the currently
* selected text.
*/
function startProcess() {
this.disabled = true; // Disable the button
$('#error').remove(); // Clear previous error messages, if any
startTimer(); // Start a work timer, for display to user
window.runningProcess = true;
dispatchWork(); // Start our work on the server
}
// Timer adapted from http://codingforums.com/javascript-programming/159873-displaying-elapsed-time.html
/**
* Kicks off the tick function.
*/
function startTimer( )
{
window.seconds = null;
window.ticker = null;
window.seconds = -1;
window.ticker = setInterval(tick, 1000);
tick( );
}
/**
* Stop ticking
*/
function stopTimer()
{
clearInterval(window.ticker);
}
/*
* Updates the timer display, between sleeps.
*/
function tick( )
{
++window.seconds;
var secs = window.seconds;
var hrs = Math.floor( secs / 3600 );
secs %= 3600;
var mns = Math.floor( secs / 60 );
secs %= 60;
var pretty = ( hrs < 10 ? "0" : "" ) + hrs
+ ":" + ( mns < 10 ? "0" : "" ) + mns
+ ":" + ( secs < 10 ? "0" : "" ) + secs;
$("#elapsed").text(pretty);
}
/**
* Inserts a div that contains an error message after a given element.
*
* #param msg The error message to display.
* #param element The element after which to display the error.
*/
function showError(msg, element) {
var div = $('<div id="error" class="error">' + msg + '</div>');
$(element).after(div);
}
</script>
I am trying to create a room availability script for a conference room at my university. I decided to achieve it using jQuery by parsing a JSON feed of a public Google calendar and then displaying on the screen whether room is available or not.
I feel stupid, I have been fighting this problems for 3 days and no matter whether there is an appointment in Google calendar or not the script says that room is available. Could anyone offer a suggestion why this may be happening. I am not a programmer and I bet this is something really simple but I just can't seem to see it. Any help would be greatly appreciated!
A minimal working example on jsFiddle and below:
<html>
<head>
<title>Graduate Center Conference Room</title>
<script src="//ajax.googleapis.com/ajax/libs/jquery/2.1.0/jquery.min.js"></script>
<script>
// Declare global variables
var events = [];
var currentReservation = null;
var nextReservation = null;
var gclaData = 'http://www.google.com/calendar/feeds/4occ2bc4m626a3pgmirlm06q5s%40group.calendar.google.com/public/full?orderby=starttime&sortorder=ascending&futureevents=true&singleevents=true&max-results=2&alt=json';
// Parse Google Calendar Public JSON Feed and store in the events global array
$(document).ready(function () {
$.getJSON(gclaData, function (data) {
$.each(data.feed.entry, function (i, entry) {
var dtStart = new Date(entry["gd$when"][0].startTime);
var dtEnd = new Date(entry["gd$when"][0].endTime);
var dtSummary = entry.content.$t;
var dtTitle = entry.title.$t;
events[i] = {
'start': dtStart,
'end': dtEnd,
'title': dtTitle,
'summary': dtSummary
};
});
});
reservationInfo = '';
// sort events just in case (JSON should be sorted anyways)
events.sort(function (a, b) {
return a.start - b.start;
});
// current date
var dtNow = new Date();
// let's assume there are no current room reservations unless script detects otherwise.
// No reservations indicated by -1
currentReservation = -1;
// loop through the events array and if current time falls between start and end of a element in the array the mark it as a reservation currently in progress
for (var i in events) {
if (dtNow >= events[i].start && dtNow <= events[i].end) currentReservation = i;
}
// Print the result to a output div
if (-1 == currentReservation) {
reservationInfo = '<h1>ROOM AVAILABLE</h1>';
$('#output').html(reservationInfo);
} else {
reservationInfo = '<h1>ROOM OCCUPIED</h1>';
$('#output').html(reservationInfo);
}
});
</script>
</head>
<body>
<div id="output"></div>
</body>
</html>
Some observations...
1) Do some refactor to your code and always do some debugging!
2) Your events variable is not the expected object since ajax calls are asynchronous and other code gets executed before getting into the callback that will fill your object. In other words, you need to wait for ajax call otherwise your object won't be the expected one (maybe will be undefined at first and after a moment, when ajax call finishes, an object with data).
Just to know, you can force an ajax call to be synchronous but that's NOT a good approach.
Try this:
I like to work this way, code it's way better organized:
Live Demo: http://jsfiddle.net/oscarj24/8HVj7/
HTML:
<div id="output"></div>
jQuery:
/*
* http://stackoverflow.com/questions/23205399/conference-room-availability-in-javascript
* #author: Oscar Jara
*/
/* Google calendar URL */
var url = 'http://www.google.com/calendar/feeds/4occ2bc4m626a3pgmirlm06q5s%40group.calendar.google.com/public/full?orderby=starttime&sortorder=ascending&futureevents=true&singleevents=true&max-results=2&alt=json';
/* Status list used to show final message to UI */
var statusList = {
'ROOM_A': 'Available',
'ROOM_O': 'Occupied',
'ERROR_DATA': 'No data found at Google calendar.',
'ERROR_PROCESS': 'There was an error checking room availability.'
};
/* Document onReady handler */
$(document).ready(function () {
getCalData(url);
});
/*
* Get Google calendar data by request.
* #param {String} url
*/
function getCalData(url) {
var statusCode;
$.getJSON(url, function (data) {
if (!$.isEmptyObject(data)) {
var events = parseCalData(data);
var curReserv = getCurrentReservation(events);
statusCode = getRoomStatusCode(curReserv);
} else {
statusCode = 'ERROR_DATA';
}
printRoomStatusToUI(statusCode, $('#output'));
}).fail(function (r) { // HTTP communication error
console.error(r);
});
};
/*
* Parse Google calendar data.
* #param {Object} data
* #return {Object} events
*/
function parseCalData(data) {
var events;
events = $.map(data.feed.entry, function (evt, i) {
var dt = evt['gd$when'][0];
return {
start: new Date(dt.startTime),
end: new Date(dt.endTime),
title: evt.title.$t,
summary: evt.content.$t
};
});
if (events) {
sortEvents(events); // Just in case
}
return events;
};
/*
* Sort Google calendar events.
* #param {Object} events
*/
function sortEvents(events) {
events.sort(function (a, b) {
return a.start - b.start;
});
}
/*
* Get/check for current reservation.
* If current time falls between start and end of an event,
* mark it as a reservation currently in progress.
* #param {Object} events
* #return {int} curReserv
*/
function getCurrentReservation(events) {
var curReserv;
if (events) {
var dtNow = new Date(); // Current datetime
curReserv = -1; // No reservations
for (var i in events) {
var dtStart = events[i].start;
var dtEnd = events[i].end;
if (dtNow >= dtStart && dtNow <= dtEnd) {
curReserv = i;
break;
}
}
}
return curReserv;
};
/*
* Get room availability statusCode.
* #param {int} curReserv
* #return {String} statusCode
*/
function getRoomStatusCode(curReserv) {
var statusCode = 'ROOM_A';
if (!curReserv) {
statusCode = 'ERROR_PROCESS';
} else if (curReserv && curReserv != -1) {
statusCode = 'ROOM_O';
}
return statusCode;
};
/*
* #private
* Get room status text.
* #param {String} statusCode
* #return {String}
*/
function getRoomStatusText(statusCode) {
return statusList[statusCode];
};
/*
* #private
* Check if statusCode is an ERROR one.
* #param {String} statusCode
* #return {Boolean}
*/
function isErrorStatus(statusCode) {
return (statusCode.indexOf('ERROR') > -1);
};
/*
* Print room availability to UI.
* #param {String} statusCode
* #param {Object} elem
*/
function printRoomStatusToUI(statusCode, elem) {
var statusText = getRoomStatusText(statusCode);
var isError = isErrorStatus(statusCode);
if (statusText && $.trim(statusText) != '') {
if (!isError) {
statusText = '<h1>Room is: ' + statusText + '</h1>';
}
elem.html(statusText);
}
};
You can quickly check what state a variable is by using:
console.log(variableName);
This will output the results of the variable in your browser console tab (in Developer Tools).
In your case, I did console.log(events); where the events were to be looped, and I discovered that events were not being set. After some debugging, I determined that the code was $.getJSON() function wasn't completing 100% before the code below it was running (most likely because the ajax request takes time).
To fix this, I've moved all of your code that parses the events within the $.getJSON() function so that the events are properly retrieved and set before parsing the data.
Your code will look like this now:
Working JSFiddle: http://jsfiddle.net/Mf8vb/4/
// Declare global variables
// Parse Google Calendar Public JSON Feed and store in the events global array
$(document).ready(function () {
var events = [];
var currentReservation = null;
var nextReservation = null;
var gclaData = 'http://www.google.com/calendar/feeds/4occ2bc4m626a3pgmirlm06q5s%40group.calendar.google.com/public/full?orderby=starttime&sortorder=ascending&futureevents=true&singleevents=true&max-results=2&alt=json';
$.getJSON(gclaData, function (data) {
$.each(data.feed.entry, function (i, entry) {
var dtStart = new Date(entry["gd$when"][0].startTime);
var dtEnd = new Date(entry["gd$when"][0].endTime);
var dtSummary = entry.content.$t;
var dtTitle = entry.title.$t;
events[i] = {
'start': dtStart,
'end': dtEnd,
'title': dtTitle,
'summary': dtSummary
};
});
reservationInfo = '';
// sort events just in case (JSON should be sorted anyways)
events.sort(function (a, b) {
return a.start - b.start;
});
// current date
var dtNow = new Date();
// let's assume there are no current room reservations unless script detects otherwise.
// No reservations indicated by -1
currentReservation = -1;
// loop through the events array and if current time falls between start and end of a element in the array the mark it as a reservation currently in progress
for (var i in events) {
if (dtNow >= events[i].start && dtNow <= events[i].end)
currentReservation = i;
}
// Print the result to a output div
if (-1 == currentReservation) {
reservationInfo = '<h1>ROOM AVAILABLE</h1>';
$('#output').html(reservationInfo);
} else {
reservationInfo = '<h1>ROOM OCCUPIED</h1>';
$('#output').html(reservationInfo);
}
});
});