5

I run a Google Apps script that uploads a file to the user's Google Drive file:

function doGet(e) {
  var blob = UrlFetchApp.fetch(e.parameters.url).getBlob();
  DriveApp.createFile(blob);
  return HtmlService.createHtmlOutput("DONE!");
}

My site loads a popup window that runs a Google Apps Script with that code. Works fine.

Now, how do I communicate back to my site that they user has successfully uploaded the file? As in, how can I communicate back to my server that the user has run doGet()?`

Some type of response handling must exist?

Full working code (test it out on JSBin):

<!DOCTYPE html>
<html>
  <head>
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.js"></script>
  </head>
  <body>
    <div class="google-upload" data-url="https://calibre-ebook.com/downloads/demos/demo.docx">
      <span style="background-color: #ddd">Upload</span>
    </div>
    <script>
      $(function() {
        $(".google-upload").click(function() {
          var url = "https://script.google.com/macros/s/AKfycbwsuIcO5R86Xgv4E1k1ZtgtfKaENaKq2ZfsLGWZ4aqR0d9WBYc/exec"; // Please input the URL here.
          var withQuery = url + "?url=";
          window.open(withQuery + $('.google-upload').attr("data-url"), "_blank", "width=600,height=600,scrollbars=1");
        });
      });
    </script>
  </body>
</html>

So to clarify, I want a way to find out whether if the user has successfully uploaded the file. Something like:

request.execute(function(response) {
    if (response.code == 'uploaded') {
        // uploaded, do stuff
    } else {
        // you get the idea...
    }
});

Adding a bounty for a complete solution to this.

Henrik Petterson
  • 6,862
  • 20
  • 71
  • 155
  • 1
    I will bounty this question with 50 points when eligible given it is a bit complex. – Henrik Petterson Mar 02 '18 at 13:13
  • 1
    Is this on a third-party site? If so, you can use `UrlFetchApp` to post a request (`200 OK`, maybe) back to your server for processing. If you're within an Apps Script web app, you can use `withSuccessHandler` chained to the `google.script.run` command. – Brian Mar 02 '18 at 15:09
  • 1
    @Brian Please see my updated question. I have added working code example. And I've started the bounty. Thanks! – Henrik Petterson Mar 05 '18 at 10:48
  • @HenrikPetterson, you are probably looking for this https://davidwalsh.name/window-postmessage ? – Tarun Lalwani Mar 05 '18 at 12:03
  • 1
    @TarunLalwani Interesting. That could potentially be an approach. Please feel free to post an answer with tested code using [jsbin](http://jsbin.com/lexodelile/1/edit?html,output). – Henrik Petterson Mar 05 '18 at 12:13
  • 1
    @TarunLalwani your suggestion of postMessage is pretty good. However, it specifically blocked by google app scripts. As mentioned [here](https://stackoverflow.com/questions/40014127/window-postmessage-to-script-google-com-as-popup) – Jack Brown Mar 09 '18 at 19:02

1 Answers1

8

Rather than returning a HtmlService object, you can pass data using jQuery's $.getJSON method and retrieve data from the doGet function with ContentService. Google Apps Script does not accept CORS, so using JSONP is the best way to get data to and from your script. See this post for more.

Working CodePen Example

I split your HTML and scripts for clarity. None of the HTML changed from your original example.

Code.gs

function doGet(e) { 
  var returnValue;

  // Set the callback param. See https://stackoverflow.com/questions/29525860/
  var callback = e.parameter.callback;

  // Get the file and create it in Drive
  try {
    var blob = UrlFetchApp.fetch(e.parameters.url).getBlob();
    DriveApp.createFile(blob);

    // If successful, return okay 
    // Structure this JSON however you want. Parsing happens on the client side.
    returnValue = {status: 'okay'};
  } catch(e) {
    Logger.log(e);
    // If a failure, return error message to the client
    returnValue = {status: e.message}
  }

  // Returning as JSONP allows for crossorigin requests
  return ContentService.createTextOutput(callback +'(' + JSON.stringify(returnValue) + ')').setMimeType(ContentService.MimeType.JAVASCRIPT);
}

Client JS

$(function() {
  $(".google-upload").click(function() {
    var appUrl = "https://script.google.com/macros/s/AKfycbyUvgKdhubzlpYmO3Marv7iFOZwJNJZaZrFTXCksxtl2kqW7vg/exec"; 
    var query = appUrl + "?url=";
    var popupUrl = query + $('.google-upload').attr("data-url") + "&callback=?";

    console.log(popupUrl)

    // Open this to start authentication.
    // If already authenticated, the window will close on its own.
    var popup = window.open(popupUrl, "_blank", "width=600,height=600,scrollbars=1");

    $.getJSON(popupUrl, function(returnValue) {
      // Log the value from the script
      console.log(returnValue.status);
      if(returnValue.status == "okay") {
        // Do stuff, like notify the user, close the window
        popup.close();
        $("#result").html("Document successfully uploaded");
      } else {
        $("#result").html(returnValue);
      }
    })
  });
});

You can test the error message by passing an empty string in the data-url param. The message is returned in the console as well as the page for the user.

Edit 3.7.18

The above solution has problems with controlling the authorization flow. After researching and speaking with a Drive engineer (see thread here) I've reworked this into a self-hosted example based on the Apps Script API and running the project as an API executable rather than an Apps Script Web App. This will allow you to access the [run](https://developers.google.com/apps-script/api/reference/rest/v1/scripts/run) method outside an Apps Script web app.

Setup

Follow the Google Apps Script API instructions for JavaScript. The Apps Script project should be a standalone (not linked to a document) and published as API executable. You'll need to open the Cloud Console and create OAuth credentials and an API key.

The instructions have you use a Python server on your computer. I use the Node JS server, http-server, but you can also put it live online and test from there. You'll need to whitelist your source in the Cloud Console.

The client

Since this is self hosted, you'll need a plain HTML page which authorizes the user through the OAuth2 API via JavaScript. This is preferrable because it keeps the user signed in, allowing for multiple API calls to your script without reauthorization. The code below works for this application and uses the authorization flow from the Google quickstart guides.

index.html

  <body>

    <!--Add buttons to initiate auth sequence and sign out-->
    <button id="authorize-button" style="display: none;">Authorize</button>
    <button id="signout-button" style="display: none;">Sign Out</button>

    <button onclick="uploadDoc()" style="margin: 10px;" id="google-upload" data-url="https://calibre-ebook.com/downloads/demos/demo.docx">Upload doc</button>

    <pre id="content"></pre>
</body>

index.js

// Client ID and API key from the Developer Console
  var CLIENT_ID = 'YOUR_CLIENT_ID';
  var API_KEY = 'YOUR_API_KEY';
  var SCRIPT_ID = 'YOUR_SCRIPT_ID';

  // Array of API discovery doc URLs for APIs used by the quickstart
  var DISCOVERY_DOCS = ["https://script.googleapis.com/$discovery/rest?version=v1"];

  // Authorization scopes required by the API; multiple scopes can be
  // included, separated by spaces.
  var SCOPES = 'https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/script.external_request';

  var authorizeButton = document.getElementById('authorize-button');
  var signoutButton = document.getElementById('signout-button');
  var uploadButton = document.getElementById('google-upload');
  var docUrl = uploadButton.getAttribute('data-url').value;

  // Set the global variable for user authentication
  var isAuth = false;

  /**
   *  On load, called to load the auth2 library and API client library.
   */
  function handleClientLoad() {
    gapi.load('client:auth2', initClient);
  }

  /**
   *  Initializes the API client library and sets up sign-in state
   *  listeners.
   */
  function initClient() {
    gapi.client.init({
      apiKey: API_KEY,
      clientId: CLIENT_ID,
      discoveryDocs: DISCOVERY_DOCS,
      scope: SCOPES
    }).then(function () {
      // Listen for sign-in state changes.
    gapi.auth2.getAuthInstance().isSignedIn.listen(updateSigninStatus);

      // Handle the initial sign-in state.
      updateSigninStatus(gapi.auth2.getAuthInstance().isSignedIn.get());
      authorizeButton.onclick = handleAuthClick;
      signoutButton.onclick = handleSignoutClick;
      // uploadButton.onclick = uploadDoc;
    });
  }

  /**
   *  Called when the Upload button is clicked. Reset the
   *  global variable to `true` and upload the document. 
   *  Thanks to @JackBrown for the logic.
   */
  function updateSigninStatus(isSignedIn) {
    if (isSignedIn && !isAuth) {
      authorizeButton.style.display = 'none';
      signoutButton.style.display = 'block';
      uploadButton.style.display = 'block'
      uploadButton.onclick  = uploadDoc;
    } else if (isSignedIn && isAuth) {
      authorizeButton.style.display = 'none';
      signoutButton.style.display = 'block';
      uploadButton.style.display = 'block';
      uploadDoc();
    } else {
      authorizeButton.style.display = 'block';
      signoutButton.style.display = 'none';
      uploadButton.style.display = 'none';
      isAuth = false;
    }
  }

  /**
   *  Sign in the user upon button click.
   */
  function handleAuthClick(event) {
    gapi.auth2.getAuthInstance().signIn();
    isAuth = true;  // Update the global variable
  }


  /**
   *  Sign out the user upon button click.
   */
  function handleSignoutClick(event) {
    gapi.auth2.getAuthInstance().signOut();
    isAuth = false;  // update the global variable
  }

  /**
   * Append a pre element to the body containing the given message
   * as its text node. Used to display the results of the API call.
   *
   * @param {string} message Text to be placed in pre element.
   */
  function appendPre(message) {
    var pre = document.getElementById('content');
    var textContent = document.createTextNode(message + '\n');
    pre.appendChild(textContent);
  }

  /**
    * Handle the login if signed out, return a Promise
    * to call the upload Docs function after signin.
  **/
  function uploadDoc() {

      console.log("clicked!")
      var docUrl = document.getElementById('google-upload').getAttribute('data-url');

      gapi.client.script.scripts.run({
        'scriptId':SCRIPT_ID,
        'function':'uploadDoc',
        'parameters': [ docUrl ]
      }).then(function(resp) {
        var result = resp.result;
        if(result.error && result.error.status) {
          // Error before the script was Called
          appendPre('Error calling API');
          appendPre(JSON.parse(result, null, 2));
        } else if(result.error) {
          // The API executed, but the script returned an error.

            // Extract the first (and only) set of error details.
            // The values of this object are the script's 'errorMessage' and
            // 'errorType', and an array of stack trace elements.
            var error = result.error.details[0];
            appendPre('Script error message: ' + error.errorMessage);

            if (error.scriptStackTraceElements) {
              // There may not be a stacktrace if the script didn't start
              // executing.
              appendPre('Script error stacktrace:');
              for (var i = 0; i < error.scriptStackTraceElements.length; i++) {
                var trace = error.scriptStackTraceElements[i];
                appendPre('\t' + trace.function + ':' + trace.lineNumber);
              }
            }
          } else {
            // The structure of the result will depend upon what the Apps
            // Script function returns. Here, the function returns an Apps
            // Script Object with String keys and values, and so the result
            // is treated as a JavaScript object (folderSet).
            console.log(resp.result)
            var msg = resp.result.response.result;
            appendPre(msg);

            // do more stuff with the response code
        }
      })
    }

Apps Script

The Apps Script code does not need to be modified much. Instead of returning using ContentService, we can return plain JSON objects to be used by the client.

function uploadDoc(e) {
  Logger.log(e);
  var returnValue = {};

  // Set the callback URL. See https://stackoverflow.com/questions/29525860/

  Logger.log("Uploading the document...");

  try { 
    // Get the file and create it in Drive
    var blob = UrlFetchApp.fetch(e).getBlob();
    DriveApp.createFile(blob);

    // If successful, return okay 
    var msg = "The document was successfully uploaded!";
    return msg;
  } catch(e) {
    Logger.log(e);
    // If a failure, return error message to the client
    return e.message
  }
}

I had a hard time getting CodePen whitelisted, so I have an example hosted securely on my own site using the code above. Feel free to inspect the source and take a look at the live Apps Script project.

Note that the user will need to reauthorize as you add or change scopes in your Apps Script project.

Brian
  • 4,274
  • 2
  • 27
  • 55
  • 1
    Would this work when the user has not already given authorization to the script? – Tarun Lalwani Mar 05 '18 at 16:29
  • 1
    Good catch. I meant to add it and forgot. I've updated the demo as well as the script above. – Brian Mar 05 '18 at 17:26
  • 1
    Brian, this is absolutely beautiful. I am testing this as we speak. There is one major issue though. When the user clicks through this for the first time (when they are asked to give permissions etc), it doesn't retrieve the data, the popup stays open. Tested so far on Firefox. But it works elegantly when you have *already* authorized the app. So clicking on the upload button a second time works! You can test this issue by running your [script](https://codepen.io/bbennett/pen/yvWQWr?editors=0011) with a new Google account that hasn't already authorized it. Is there a solution to this? – Henrik Petterson Mar 06 '18 at 11:04
  • @Brian I went on a similar route as you but didn't make it work. Very likely because I tried to close the popup window directly inside the google script via a custom html file. I gave your solution a test run and it worked **nearly** flawlessly. Experienced the same problem as Henrik; it does not work on the first run when authorising. Great work regardless! Looking forward to seeing a fully working version. +1 – Gary Woods Mar 07 '18 at 11:39
  • 1
    I've added a significant update to my answer with a working sample using the Apps Script API. – Brian Mar 07 '18 at 14:48
  • 2
    @Brian I am testing this as we speak, thank you. While playing around with your demo, I noticed the authorization process is separate to the upload button. Meaning, the user needs to authorize first by clicking it, and then they need to click the upload button. Is there a way to merge these (similar to the previous workflow)? In terms of UX, this can be a nightmare. – Henrik Petterson Mar 08 '18 at 11:53
  • 1
    Lovely Q&A. Agree about "merging" them. `uploadDoc()` should trigger authentication if user hasn't authorized/signed in. Is that possible? Hoping for an edit. Side note: I am working on a WordPress script where we make a bridge between WP and Drive. I will use some of your approach and credit you accordingly. Will send you an email once we have something and you can have a look. Cheers! – Christine Cooper Mar 08 '18 at 13:27
  • @HenrikPetterson JavaScript is single-threaded, so the short answer is no because you cannot suspend a single function while some other process happens without using Web Workers or building a `Promise` chain. [See this SO post](https://stackoverflow.com/questions/11563160/suspending-a-js-script-until-an-event-occurs) for more details on multithreading. I've added an optional catch if the user is not signed in, but they'll need to do two clicks one way or the other. – Brian Mar 08 '18 at 13:30
  • 3
    @Brian I believe you can merge the authorization process and upload functionality, using a boolean flag variable. I set a boolean variable to true if `handleAuthClick(event)` is activated . After the user logins, `updateSigninStatus()` is called, where I check the login status and check if boolean variable is true and then call `uploadDoc()`. But if the boolean variable is false I show the sign-out and upload button for the user to choose from. Working example [here](https://script.google.com/macros/s/AKfycbwJqRr513eQvXTSk54gpvmK7iDoU6-vUCWrdts5HrkdR2K6A628/exec) – Jack Brown Mar 09 '18 at 03:57
  • @Brian Looks like Jack's solution may be it. What do you think? Could you kindly update your answer with this approach so we have the final solution locally on this site? – Henrik Petterson Mar 09 '18 at 11:24
  • @HenrikPetterson I've updated the example above based on Jack's suggestion. The live example is also updated. – Brian Mar 09 '18 at 13:07
  • @Brian Thank you very much Brian. Testing this over the weekend. – Henrik Petterson Mar 10 '18 at 14:59
  • @JackBrown Any chance you could share how you added the URL of the uploaded file? That is a great feature! – Henrik Petterson Mar 10 '18 at 14:59
  • @Henrik Petterson You can find an example of this feature [here](https://stackoverflow.com/questions/48796360/app-script-how-to-view-pdf-of-google-sheet-after-function-to-save-to-google-dri/48814352#48814352). – Jack Brown Mar 11 '18 at 11:24