Google-apps-script Client Validation

I recently saw a question on stackoverflow where someone was asking if it was possible to do a two-factor clienthandler to validate entries.  Since I've only been playing with apps scripts for a week (see my other posts on adventures with archiving Gmail - which I still have to finish up), I figured I'd give it a try to learn a bit more.

The scenario was he (or she) had two textboxes and wanted to allow a button to be clicked if either BOTH were empty OR BOTH had data.  My solution, while cumbersome, actually works.

I created two additional textboxes (which could be hidden) then piled on the validators on the original two texboxes. 

  • If entryTextbox1.length>0, validatorTextbox1.text=1 else validatorTextbox1=0,
  • If entryTextbox2.length>0, validatorTextbox2.text=1 else validatorTextbox2=0

Then some textchangehandlers

  • validatorTextbox1.onTextChange: if sum(validatorText1+validatorTextbox2)=1, don't allow click
  • validatorTextbox2.onTextChange: if sum(validatorText1+validatorTextbox2)=1, don't allow click

To handle databoxes, the theory is the same, but

  1. lengthchecks on dateboxes don't work properly they throw an error for some reason. i used a regex testing for the '-' character. you may need to change this if you've formated your date differently.  Or you could fully validate the date format.
  2. you need to fire events for invalid dates to handle empty fields
  3. and lastly, you need to use the valuechange handler instead as it was correctly pointed out it doesn't have onkeyup

Click Here to see the solution in action.


function doGet() {
var app = UiApp.createApplication();


var buttonDate = app.createButton('Click Me');
app.add(app.createLabel("This demonstrates using clienthandlers to require either neither or both dateBoxes to be populated before the button can be clicked"));
var validationWarning=app.createLabel("Please enter a value in both fields or neither field").setId('warningLabel').setStyleAttribute('color', "red").setVisible(false);
app.add(buttonDate);
app.add(validationWarning);
var panel = app.createVerticalPanel();


// db1.addValueChangeHandler(handler);

var db1=app.createDateBox().setId('db1').setName('db1');
var db2=app.createDateBox().setId('db2').setName('db2');
//Hide these two - but i left them visible so you can see the script in action.
var tbValidatorFordb1=app.createTextBox().setId('tb3').setName('tb3').setEnabled(false);
var tbValidatorFordb2=app.createTextBox().setId('tb4').setName('tb4').setEnabled(false);
var label1 = app.createLabel('The button was clicked.')
.setId('statusLabel')
.setVisible(false);


panel.add(label1);

var grid1=app.createGrid(2, 2);
grid1.setWidget(0, 0, app.createLabel("Field 1"));
grid1.setWidget(0, 1, app.createLabel("Field 2"));
grid1.setWidget(1, 0,db1);
grid1.setWidget(1, 1,db2);
panel.add(grid1);

var grid2=app.createGrid(2, 2);
grid2.setWidget(0, 0, app.createLabel("Validator 1"));
grid2.setWidget(0, 1, app.createLabel("Validator 2"));
grid2.setWidget(1, 0,tbValidatorFordb1);
grid2.setWidget(1, 1,tbValidatorFordb2);
panel.add(grid2);

app.add(panel);
//Set your validators for your first textbox

var clientHandler1=app.createClientHandler().validateMatches(db1,'\-').forTargets(tbValidatorFordb1).setText('1');
var clientHandler2=app.createClientHandler().validateNotMatches(db1, '\-').forTargets(tbValidatorFordb1).setText('0');

//Set your validators for your second textbox
var clientHandler3=app.createClientHandler().validateMatches(db2,'\-').forTargets(tbValidatorFordb2).setText('1');
var clientHandler4=app.createClientHandler().validateNotMatches(db2,'\-').forTargets(tbValidatorFordb2).setText('0');
//tb1.addValueChangeHandler(handler)
db1.addValueChangeHandler(clientHandler1);
db1.addValueChangeHandler(clientHandler2);
db2.addValueChangeHandler(clientHandler3);
db2.addValueChangeHandler(clientHandler4);
db2.addValueChangeHandler(clientHandler1);
db2.addValueChangeHandler(clientHandler2);
db1.addValueChangeHandler(clientHandler3);
db1.addValueChangeHandler(clientHandler4);

//Now create some client handlers to do the actual enable/disable of the button
db1.setFireEventsForInvalid(true);
db2.setFireEventsForInvalid(true);
var finalDisableValidator1=app.createClientHandler().validateSum([tbValidatorFordb1,tbValidatorFordb2], 1).forTargets(buttonDate).setEnabled(false).forTargets(validationWarning).setVisible(true);
var finalEnableValidator1=app.createClientHandler().validateNotSum([tbValidatorFordb1,tbValidatorFordb2], 1).forTargets(buttonDate).setEnabled(true).forTargets(validationWarning).setVisible(false);

db1.addValueChangeHandler(finalDisableValidator1);
db1.addValueChangeHandler(finalEnableValidator1);
db2.addValueChangeHandler(finalDisableValidator1);
db2.addValueChangeHandler(finalEnableValidator1);

//tb4.addChangeHandler(finalEnableValidator)
var handler = app.createServerHandler('myClickHandler').validateNotSum([tbValidatorFordb1,tbValidatorFordb2], 1);
handler.addCallbackElement(label1);
buttonDate.addClickHandler(handler);


//Code for textboxes

var button2 = app.createButton('Click Me');
var panel2 = app.createVerticalPanel();
panel2.add(app.createLabel("This demonstrates using clienthandlers to require either neither or both textBoxes to be populated before the button can be clicked"));
var validationWarning2=app.createLabel("Please enter a value in both fields or neither field").setId('warningLabel').setStyleAttribute('color', "red").setVisible(false);

panel2.add(button2);
panel2.add(validationWarning2);



// db1.addValueChangeHandler(handler);

var tb1=app.createTextBox().setId('tb1').setName('tb1');
var tb2=app.createTextBox().setId('tb2').setName('tb2');
//Hide these two - but i left them visible so you can see the script in action.
var tbValidatorForTb1=app.createTextBox().setId('tb5').setName('tb5').setEnabled(false);
var tbValidatorForTb2=app.createTextBox().setId('tb6').setName('tb6').setEnabled(false);
var label = app.createLabel('The button was clicked.')
.setId('statusLabel')
.setVisible(false);


panel2.add(label);

var grid3=app.createGrid(2, 2);
grid3.setWidget(0, 0, app.createLabel("Field 1"));
grid3.setWidget(0, 1, app.createLabel("Field 2"));
grid3.setWidget(1, 0,tb1);
grid3.setWidget(1, 1,tb2);
panel2.add(grid3);

var grid4=app.createGrid(2, 2);
grid4.setWidget(0, 0, app.createLabel("Validator 1"));
grid4.setWidget(0, 1, app.createLabel("Validator 2"));
grid4.setWidget(1, 0,tbValidatorForTb1);
grid4.setWidget(1, 1,tbValidatorForTb2);
panel2.add(grid4);

app.add(panel2);
//Set your validators for your first textbox
var clientHandler5=app.createClientHandler().validateLength(tb1, 1, 200).forTargets(tbValidatorForTb1).setText('1');
var clientHandler6=app.createClientHandler().validateLength(tb1,0, 0).forTargets(tbValidatorForTb1).setText('0');


//Set your validators for your second textbox
var clientHandler7=app.createClientHandler().validateLength(tb2, 1, 200).forTargets(tbValidatorForTb2).setText('1');
var clientHandler8=app.createClientHandler().validateLength(tb2,0, 0).forTargets(tbValidatorForTb2).setText('0');
//tb1.addValueChangeHandler(handler)
tb1.addKeyUpHandler(clientHandler5);
tb1.addKeyUpHandler(clientHandler6);
tb2.addKeyUpHandler(clientHandler7);
tb2.addKeyUpHandler(clientHandler8);
tb2.addKeyUpHandler(clientHandler5);
tb2.addKeyUpHandler(clientHandler6);
tb1.addKeyUpHandler(clientHandler7);
tb1.addKeyUpHandler(clientHandler8);

//Now create some client handlers to do the actual enable/disable of the button

var finalDisableValidator=app.createClientHandler().validateSum([tbValidatorForTb1,tbValidatorForTb2], 1).forTargets(button2).setEnabled(false).forTargets(validationWarning).setVisible(true);
var finalEnableValidator=app.createClientHandler().validateNotSum([tbValidatorForTb1,tbValidatorForTb2], 1).forTargets(button2).setEnabled(true).forTargets(validationWarning).setVisible(false);

tb1.addKeyUpHandler(finalDisableValidator);
tb1.addKeyUpHandler(finalEnableValidator);
tb2.addKeyUpHandler(finalDisableValidator);
tb2.addKeyUpHandler(finalEnableValidator);

//tb4.addChangeHandler(finalEnableValidator)
var handler = app.createServerHandler('myClickHandler').validateNotSum([tbValidatorForTb1,tbValidatorForTb2], 1);
handler.addCallbackElement(label);
button2.addClickHandler(handler);




return app;
}

function myClickHandler(e) {
var app = UiApp.getActiveApplication();

var label = app.getElementById('statusLabel');
label.setVisible(true);

app.close();
return app;
}

Archiving Gmail (Part 3)

Time for a little aside.  In the last post I showed you were we were headed with a Google apps script web application that will archive or forward gmail messages in bulk.  Running this script can take a while if there are a lot of emails and Google apps scripts don't have a very good method for showing that they are running.

While I wanted a progress bar, I couldn't find anything that would easily get me there (although if you read Solution 2 below you could back your way into one but it would be cumbersome at best).

I settled on showing and hiding a spinning gif to indicate to the user the script is still running.  I found two ways to do this, but only one (the second) works with the menus I wanted to use.

Solution 1:

This solution does NOT work with menus.  Basically the idea is you add a normal server handler to a button (for example) then add an additional client handler to the MouseUp event. Click to see it in action.

Here is the simple code for that demo:

// Script-as-app template.
function doGet() {
  var app = UiApp.createApplication();

  var label = app.createLabel('click the button.')
                 .setId('statusLabel')
                 .setVisible(true);
  
  app.add(label);
  var spinner = app.createImage('https://5079980847011989849-a-1802744773732722657'+
            '-s-sites.googlegroups.com/site/scriptsexamples/ProgressSpinner.gif')
            .setVisible(false)
            .setId('spinner');
  app.add(spinner);

  
  var myButtonHandler=app.createServerHandler('myButtonLongRunningFunction');
  
  var myButton=app.createButton("Click to Test", myButtonHandler);
    var loadSpinner = app.createClientHandler()
            .forTargets(spinner)
            .setVisible(true).forTargets(label).setText('Running...');

  myButton.addMouseUpHandler(loadSpinner)
  app.add(myButton);
  return app;
  
}

function myButtonLongRunningFunction(e){

  Utilities.sleep(5000);
  
  var app=UiApp.getActiveApplication();
  app.getElementById('statusLabel').setText('Done.');
  app.getElementById('spinner').setVisible(false);
  return app;
}

Solution 2:

As I stated, the above method will NOT work if you use a menu.  Why? Because you can't assign both a client handler and server handler to a menuItem.   So how can we accomplish the same thing using only a server handler?  By tricking the script into running TWO server handlers asynchronously.  Click here to see this in action

Here are the basic steps:

  1. Create your menu
  2. Create an object like a textbox to hold the value of the menu item clicked
  3. Create a checkbox with a ValueChangeHandler.  this handler will check the textbox for the command that was requested, and perform some long running action.
  4. create a menu handler for each menuitem.  these menu handlers will show the progress indicator, set the value in the textbox, then toggle the checkbox value to trigger the ValueChangeHandler created above.

Here is the code for example two:

 

// Script-as-app template.
function doGet() {
  var app = UiApp.createApplication();

  //Create menu
      var menuBar = app.createMenuBar();//For horizontal menubar
    var handler1 = app.createServerHandler('myMenu1Handler');
    var handler2 = app.createServerHandler('myMenu2Handler');
  
  
    var menuItem0 = app.createMenuItem('Item 1', handler1);
    var menuItem1 = app.createMenuItem('Item 2', handler2);
      var separator0 = app.createMenuItemSeparator();
    var separator1 = app.createMenuItemSeparator();
    menuBar.addSeparator(separator0).addItem(menuItem0).addSeparator(separator1)
    .addItem(menuItem1);
  app.add(menuBar);
  
  //Create the control checkbox trigger and textbox
  var myControlTextBox=app.createTextBox().setId('controlTextBox').setName('controlTextBox').setVisible(false);  
  var controlHandler=app.createServerHandler('myControlHandler');
  
  var myControlCheckBox=app.createCheckBox().setId('controlCheckBox').setName('controlCheckBox').addValueChangeHandler(controlHandler).setVisible(false);

  controlHandler.addCallbackElement(myControlTextBox);
  controlHandler.addCallbackElement(myControlCheckBox);

  app.add(myControlTextBox);
  app.add(myControlCheckBox);

  //create the spinner we will use for progress
    var spinner = app.createImage('https://5079980847011989849-a-1802744773732722657'+
            '-s-sites.googlegroups.com/site/scriptsexamples/ProgressSpinner.gif')
            .setVisible(false)
            .setId('spinner');
  app.add(spinner);
  var resultsLabel=app.createLabel().setVisible(false).setId('resultsLabel');
  app.add(resultsLabel);
  return app;
}

function myControlHandler(e){
  Logger.log("control called");
  var app=UiApp.getActiveApplication();
  if(e.parameter.controlCheckBox=='true'){
    if(e.parameter.controlTextBox=='Menu1'){
      
      Utilities.sleep(3000);
      
      app.getElementById('spinner').setVisible(false);
      app.getElementById('resultsLabel').setText('Menu 1 was clicked').setVisible(true);
      //reset control box
      var controlCheck=app.getElementById('controlCheckBox');
      controlCheck.setValue(false,false);
    } else if(e.parameter.controlTextBox=='Menu2'){
      Utilities.sleep(3000);
      app.getElementById('spinner').setVisible(false);
      app.getElementById('resultsLabel').setText('Menu 2 was clicked').setVisible(true);
      //reset control box
      var controlCheck=app.getElementById('controlCheckBox');
      controlCheck.setValue(false,false);
    } else {
      Logger.log("controlTextBox value: " + e.parameter.controlTextBox);
    }
  } else {
    //Do nothing - this is result of reset from above.
  }
  return app;
}
function myMenu1Handler(e) {
  var app = UiApp.getActiveApplication();
  //show the spinner
  app.getElementById('spinner').setVisible(true);
  app.getElementById('controlTextBox').setText('Menu1');
  app.getElementById('resultsLabel').setVisible(false);
  var controlCheck=app.getElementById('controlCheckBox');
  controlCheck.setValue(true,true);
  return app;
}
function myMenu2Handler(e) {
  var app = UiApp.getActiveApplication();
  //show the spinner
  app.getElementById('spinner').setVisible(true);
  app.getElementById('controlTextBox').setText('Menu2');
  app.getElementById('resultsLabel').setVisible(false);
   var controlCheck=app.getElementById('controlCheckBox');
  controlCheck.setValue(true,true);
  return app;
}

Since I decided that I liked the look of the menu better, I went with option 2.

to be continued....

Archiving Gmail (Part 2)

In my last post, I addressed one of the issues I had with a script I had found that archives Gmail messages to PDF files and how to fix the issue of it creating needless temporary files that count toward your daily quota.

The remaining issues I had I addressed by moving the entire script from a Google spreadsheet to a Google web app script.  This was the original script UI:

Screen Shot 2013-11-20 at 8.38.27 AM.png

And here is the new UI:

DemoApp.png

As you can see, I it has a few more options than the original.  The gist of it is

  • you select the Gmail Label you want to use as a filter, you optionally select a label you want to ignore (You could create two labels for example 'Archive' and 'Processed').  You can also add your own custom filter to include or exclude additional messages.  
  • Choose if you want to save the attachments (If you are archiving the emails to PDF).
  • Optionally you can have it remove the first Label (e.g. 'Archive') and Optionally add a new label (e.g. 'Processed').  Both of these are helpful when you have a large number of emails to process and may bump against your daily Quotas for creating or forwarding mail.
  • If you want to Archive them, you can archive them to PDF with the attachments saved as separate files, or you can save them to EML files with the attachments embedded in the original email.  You can change the naming convention to a few different formats (This makes it easier to sort them later so you could specify From-Date-Id instead for example)
  • Or you can bulk forward them to some specified recipient(s).

If you want to see the finished product here is the link.  You will need to authorize it to access your mailbox etc since obviously it is processing mail.

 

So where to begin?

First you need to setup your Google Drive to write the scripts.  

  • In Drive, Click Create -> Connect More Apps
Screen Shot 2013-11-20 at 8.56.36 AM.png
  • Search for "script" and connect to Google Apps Script
Screen Shot 2013-11-20 at 8.59.24 AM.png
  • Now create your first script by creating a new script.  The type you want will be Script as Web App which will be available after you choose Script from the below image.
Screen Shot 2013-11-20 at 9.01.05 AM.png
  • You will now have your development environment all set to start scripting!
Screen Shot 2013-11-20 at 9.04.23 AM.png

Well, this is getting rather long, so in the next post I will just dump the code for the app I wrote and walk you through it.  Click Here to see Part 3

Archiving Google Mail To Files

I was recently tasked with extracting 2-300 gmail messages including attachments.  The requirements seemed simple enough:

  • Every message must include original meta data (from/to/cc/bcc/date etc)
  • all attachments must be saved in original format

Obviously one way would have been to sync the messages to outlook, create a folder in outlook, copy and/or move the messages to that folder, then export it to PST.  

But it seemed like there should be a 'native' gmail way to do it.  So I started poking around and found a lot of Google Apps Scripts had been written that would bulk forward or archive messages.   This one by Marcello Scacchetti caught my eye. 

But as I started using it, I realized there were a few aspects I didn't really like:

  1. It doesn't save the meta data
  2. It creates temp files in Google Drive
  3. Hard coded naming conventions
  4. Hard coded to PDF output.
  5. It hard coded labels
  6. It was built in a spreadsheet.

To keep this post short, I will break this into several posts and only address one issue here.  Since #1 is easy enough for the average bear to fix themselves so I won't bother addressing that.  The first real problem was with #2. Here we see the offending code:

 // Create the message PDF inside the new folder
var htmlBodyFile = newFolder.createFile('body.html', messageBody, "text/html");
var pdfBlob = htmlBodyFile.getAs('application/pdf');
pdfBlob.setName(newFolderName + ".pdf");
newFolder.createFile(pdfBlob);
htmlBodyFile.setTrashed(true);

Before it creates the pdf file, it creates an HTML file.  Why is this a problem? Well in scripts (particularly individual not paid corporate gmail accounts) there are quotas on how many files you can create via script per day - 250 as of the time of this writing.  That means that you could only archive 125 emails with this, and then only if none of them had attachments.  Luckily this is easy enough to fix:

var output = HtmlService.createHtmlOutput(messageBody)
var pdfBlob=output.getAs('application/pdf');
pdfBlob.setName(newFolderName + ".pdf");
newFolder.createFile(pdfBlob);
Here we use the HTMLService to create an object rather than a file. Easy fix and doubles the number of emails we can process per day.

I'll post some solutions to my other issues with the script in the next few days.