How to implement fullStory with Google Sheet Apps Script Add-on - google-apps-script

I am new to Google Apps Script Add-on. I want to implement fullStory for data capture
and user experience. I had developed the fullstory in the excel add-in successfully, but when I tried to create the same for the google app script add-on it was not working.
I have added the script tag in google sheet add-on but it's not working as per it's document.
I have followed the below step:
https://help.fullstory.com/hc/en-us/articles/360020828273-Getting-Started-with-FullStory
I have added the below script in head section of add-on HTML file.
<script>
window['_fs_host'] = 'fullstory.com';
window['_fs_script'] = 'edge.fullstory.com/s/fs.js';
window['_fs_org'] = 'id';
window['_fs_namespace'] = 'FS';
(function(m,n,e,t,l,o,g,y){
if (e in m) {if(m.console && m.console.log) { m.console.log('FullStory namespace conflict. Please set window["_fs_namespace"].');} return;}
g=m[e]=function(a,b,s){g.q?g.q.push([a,b,s]):g._api(a,b,s);};g.q=[];
o=n.createElement(t);o.async=1;o.crossOrigin='anonymous';o.src='https://'+_fs_script;
y=n.getElementsByTagName(t)[0];y.parentNode.insertBefore(o,y);
g.identify=function(i,v,s){g(l,{uid:i},s);if(v)g(l,v,s)};g.setUserVars=function(v,s){g(l,v,s)};g.event=function(i,v,s){g('event',{n:i,p:v},s)};
g.anonymize=function(){g.identify(!!0)};
g.shutdown=function(){g("rec",!1)};g.restart=function(){g("rec",!0)};
g.log = function(a,b){g("log",[a,b])};
g.consent=function(a){g("consent",!arguments.length||a)};
g.identifyAccount=function(i,v){o='account';v=v||{};v.acctId=i;g(o,v)};
g.clearUserCookie=function(){};
g.setVars=function(n, p){g('setVars',[n,p]);};
g._w={};y='XMLHttpRequest';g._w[y]=m[y];y='fetch';g._w[y]=m[y];
if(m[y])m[y]=function(){return g._w[y].apply(this,arguments)};
g._v="1.3.0";
})(window,document,window['_fs_namespace'],'script','user');
</script>
Code of Apps Script
Login.html
<head>
<base target="_top">
<script type="module" src="https://unpkg.com/#fluentui/web-components"></script>
<?!= include('stylesheet'); ?>
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/toastify-js/src/toastify.min.css">
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/toastify-js"></script>
<!-- Style CDN -->
<link href="https://fonts.cdnfonts.com/css/graphik" rel="stylesheet" crossorigin="anonymous">
<style>
body {
margin: 0;
padding: 0;
}
</style>
<script>
window['_fs_host'] = 'fullstory.com';
window['_fs_script'] = 'edge.fullstory.com/s/fs.js';
window['_fs_org'] = 'id';
window['_fs_namespace'] = 'FS';
(function (m, n, e, t, l, o, g, y) {
if (e in m) { if (m.console && m.console.log) { m.console.log('FullStory namespace conflict. Please set window["_fs_namespace"].'); } return; }
g = m[e] = function (a, b, s) { g.q ? g.q.push([a, b, s]) : g._api(a, b, s); }; g.q = [];
o = n.createElement(t); o.async = 1; o.crossOrigin = 'anonymous'; o.src = 'https://' + _fs_script;
y = n.getElementsByTagName(t)[0]; y.parentNode.insertBefore(o, y);
g.identify = function (i, v, s) { g(l, { uid: i }, s); if (v) g(l, v, s) }; g.setUserVars = function (v, s) { g(l, v, s) }; g.event = function (i, v, s) { g('event', { n: i, p: v }, s) };
g.anonymize = function () { g.identify(!!0) };
g.shutdown = function () { g("rec", !1) }; g.restart = function () { g("rec", !0) };
g.log = function (a, b) { g("log", [a, b]) };
g.consent = function (a) { g("consent", !arguments.length || a) };
g.identifyAccount = function (i, v) { o = 'account'; v = v || {}; v.acctId = i; g(o, v) };
g.clearUserCookie = function () { };
g.setVars = function (n, p) { g('setVars', [n, p]); };
g._w = {}; y = 'XMLHttpRequest'; g._w[y] = m[y]; y = 'fetch'; g._w[y] = m[y];
if (m[y]) m[y] = function () { return g._w[y].apply(this, arguments) };
g._v = "1.3.0";
})(window, document, window['_fs_namespace'], 'script', 'user');
</script>
</head>
macro.gs
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('Custom Menu')
.addItem('Show sidebar', 'showSidebar')
.addToUi();
}
function doGet() {
return HtmlService.createHtmlOutputFromFile('home');
}
function include(filename) {
return HtmlService.createHtmlOutputFromFile(filename)
.getContent();
}
function showSidebar() {
var html = HtmlService.createTemplateFromFile('login').evaluate().setTitle("Add-on")
SpreadsheetApp.getUi()
.showSidebar(html);
}
I have provided the image of network pane for fullStory.
Image of privacy Section
Here is the link for fullStory document.
https://developer.fullstory.com/introduction
I don't know why it's not working.
Can anyone guide me on this?

Your client side of the sidebar is sent as a string to be injected into an iframe.
One thing that you could try that others have found worked for them is adding this additional flag in script:
window['_fs_is_outer_script'] = true;
You can get more information on this flag in the iframe article :
Hope this will help you.

I'm not familiar with fullstory, but I see that your add-on is opening the client-side code in a sidebar. In this case the client-side of the sidebar is sent as as string to be injected into an iframe, so it will be constrained to the sidebar dimensions.
In other other words, if you are intending to use fullstory over the grid and other Google Sheets UI elements, it will not work.
Anyway, please be aware that the client-side code is satinized so the parts that don't pass the sanitization will not be included, so use the browser developer tools to validate that the whole client-side code is sent.
P.S. I suggest you to include <!DOCTYPE html> and <html>, </html>.
Related
Where is my iframe in the published web application/sidebar?
Resources
https://developers.google.com/apps-script/guides/html/best-practices

Related

Drive file picker from the older Google Sign-In platform library to the newer Google Identity Services library for authentication

I have a code where I read the file data as blob. I have implemented using the old gapi, how do I migrate from the older Google Sign-In platform library to the newer Google Identity Services library for authentication.
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8" />
<title>Google Picker Example</title>
</head>
<body>
<button id="authorize_button" style="display: none;">Authorize</button>
<button id="signout_button" style="display: none;">Sign Out</button>
<div id="result"></div>
<script type="text/javascript" src="script.js"></script>
<script async defer src="https://apis.google.com/js/api.js"
onload="this.onload=function(){};handleClientLoad()"
onreadystatechange="if (this.readyState === 'complete') this.onload()">
</script>
<script>
const API_KEY = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';
const CLIENT_ID = '995979103146-6qdmadbu7ha5ptrthsg8uqonkpplvc8e.apps.googleusercontent.com';
const appId = "995979103146";
const SCOPES = ["https://www.googleapis.com/auth/drive"];
const DISCOVERY_DOCS = [
"https://www.googleapis.com/discovery/v1/apis/drive/v3/rest",
];
const authorizeButton = document.getElementById("authorize_button");
const signoutButton = document.getElementById("signout_button");
// Use the Google API Loader script to load the google.picker script.
function handleClientLoad() {
gapi.load("client:auth2:picker", initClient);
}
function initClient() {
gapi.client.init({
apiKey: API_KEY,
clientId: CLIENT_ID,
discoveryDocs: DISCOVERY_DOCS,
scope: SCOPES[0]
})
.then(
function () {
// Listen for sign-in state changes.
gapi.auth2.getAuthInstance().isSignedIn.listen(handleSignIn);
// Handle the initial sign-in state.
handleSignIn(gapi.auth2.getAuthInstance().isSignedIn.get());
authorizeButton.onclick = handleAuthClick;
signoutButton.onclick = handleSignoutClick;
},
function (error) {
appendPre(JSON.stringify(error, null, 2));
}
);
}
function handleSignIn(isSignedIn) {
if (isSignedIn) {
authorizeButton.style.display = "none";
signoutButton.style.display = "block";
createPicker();
} else {
authorizeButton.style.display = "block";
signoutButton.style.display = "none";
}
}
function handleAuthClick(event) {
gapi.auth2.getAuthInstance().signIn();
}
function handleSignoutClick(event) {
gapi.auth2.getAuthInstance().signOut();
}
function createPicker() {
const token = gapi.client.getToken().access_token
if (token) {
let view = new google.picker.View(google.picker.ViewId.DOCS);
view.setMimeTypes("image/png,image/jpeg,image/jpg");
let picker = new google.picker.PickerBuilder()
.enableFeature(google.picker.Feature.NAV_HIDDEN)
.enableFeature(google.picker.Feature.MULTISELECT_ENABLED)
.setAppId(appId)
.setOAuthToken(token)
.addView(view)
.addView(new google.picker.DocsUploadView())
.setDeveloperKey(API_KEY)
.setCallback(getFile)
.build();
picker.setVisible(true);
}
}
function getFile(pickerResp) {
console.log(JSON.stringify(pickerResp))
if(pickerResp.action == "picked") {
gapi.client.drive.files
.get({
fileId: pickerResp.docs[0].id,
alt: 'media'
})
.then(resp => {
console.log("fetch response", resp.status)
let binary = resp.body
// EDIT - addition from Gabrielle vvvv
let l = binary.length
let array = new Uint8Array(l);
for (var i = 0; i<l; i++){
array[i] = binary.charCodeAt(i);
}
let blob = new Blob([array], {type: 'application/octet-stream'});
console.log(blob)
// EDIT - addition from Gabrielle ^^^^
});
}
}
</script>
</body>
</html>
It is really easy to implement the new Google Identity Library. As you can compare here, you only have to change a few things.
This example is the easiest approach (implicit flow):
One button for retrieving the token
One button for loading the picker
<html>
<body>
<script
src="https://accounts.google.com/gsi/client"
onload="initClient()"
async
defer
></script>
<script>
var client;
var access_token;
function loadPicker() {
gapi.load('picker', {'callback': ()=>console.log("Picker Loaded")});
}
function initClient() {
client = google.accounts.oauth2.initTokenClient({
client_id: "<CLIENT_ID>",
scope:
"https://www.googleapis.com/auth/drive.file",
callback: (tokenResponse) => {
access_token = tokenResponse.access_token;
},
});
}
function getToken() {
client.requestAccessToken();
}
function revokeToken() {
google.accounts.oauth2.revoke(access_token, () => {console.log('access token revoked')});
}
function onPickerApiLoad() {
var picker = new google.picker.PickerBuilder()
.addView(google.picker.ViewId.DOCS)
.setOAuthToken(access_token)
.build();
picker.setVisible(true);
}
</script>
<!-- The Google API Loader script. -->
<h1>Google Identity Services Authorization Token model</h1>
<button onclick="getToken();">Get access token</button><br /><br />
<button onclick="onPickerApiLoad()">Load picker</button>
<script src="https://apis.google.com/js/api.js?onload=loadPicker"></script>
</body>
</html>
Documentation:
Migrate to Google Identity Services
Google has released a new Code sample I would start there to see the changes for authorizaotn. It should just be a matter of swapping out the authorization methods. The rest of the code is unchanged.
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8" />
<title>Google Picker Example</title>
<script type="text/javascript">
// The Browser API key obtained from the Google API Console.
// Replace with your own Browser API key, or your own key.
var developerKey = 'xxxxxxxYYYYYYYY-12345678';
// The Client ID obtained from the Google API Console. Replace with your own Client ID.
var clientId = "1234567890-abcdefghijklmnopqrstuvwxyz.apps.googleusercontent.com"
// Replace with your own project number from console.developers.google.com.
// See "Project number" under "IAM & Admin" > "Settings"
var appId = "1234567890";
// Scope to use to access user's Drive items.
var scope = ['https://www.googleapis.com/auth/drive.file'];
var pickerApiLoaded = false;
var oauthToken;
// Use the Google API Loader script to load the google.picker script.
function loadPicker() {
gapi.load('auth', {'callback': onAuthApiLoad});
gapi.load('picker', {'callback': onPickerApiLoad});
}
function onAuthApiLoad() {
window.gapi.auth.authorize(
{
'client_id': clientId,
'scope': scope,
'immediate': false
},
handleAuthResult);
}
function onPickerApiLoad() {
pickerApiLoaded = true;
createPicker();
}
function handleAuthResult(authResult) {
if (authResult && !authResult.error) {
oauthToken = authResult.access_token;
createPicker();
}
}
// Create and render a Picker object for searching images.
function createPicker() {
if (pickerApiLoaded && oauthToken) {
var view = new google.picker.View(google.picker.ViewId.DOCS);
view.setMimeTypes("image/png,image/jpeg,image/jpg");
var picker = new google.picker.PickerBuilder()
.enableFeature(google.picker.Feature.NAV_HIDDEN)
.enableFeature(google.picker.Feature.MULTISELECT_ENABLED)
.setAppId(appId)
.setOAuthToken(oauthToken)
.addView(view)
.addView(new google.picker.DocsUploadView())
.setDeveloperKey(developerKey)
.setCallback(pickerCallback)
.build();
picker.setVisible(true);
}
}
// A simple callback implementation.
function pickerCallback(data) {
if (data.action == google.picker.Action.PICKED) {
var fileId = data.docs[0].id;
alert('The user selected: ' + fileId);
}
}
</script>
</head>
<body>
<div id="result"></div>
<!-- The Google API Loader script. -->
<script type="text/javascript" src="https://apis.google.com/js/api.js?onload=loadPicker"></script>
</body>
</html>

Can I modify HtmlService Output using Google Apps Script?

I made an web app which copies folder and files of google drive. My goal is to show the name of the file and folder which is being copied on the web page('messages' id). Is it possible to change the web page from the google apps script?
When I open the web app, the GAS shows an output page using a form.html, and when I push the copy button, it will start start() function and begin copying the folders. After the successful process, the successHandler will call onSuccess() and it will change the innerHTML to Success.
I'd like to change the innerHTML during the copy process according to the folders' and files' name, but I don't know how to change it from the GAS function start() or copyFolder().
Thank you.
code.gs
function doGet(){
return HtmlService.createHtmlOutputFromFile('form');
}
function start() {
var sourceFolderId = "FOLDERID";
var targetFolder = "TARGET_FOLDER_NAME";
var source = DriveApp.getFolderById(sourceFolderId);
var target = DriveApp.createFolder(targetFolder);
copyFolder(source, target);
}
function copyFolder(source, target) {
var folders = source.getFolders();
var files = source.getFiles();
while(files.hasNext()) {
var file = files.next();
file.makeCopy(file.getName(), target);
}
while(folders.hasNext()) {
var subFolder = folders.next();
var folderName = subFolder.getName();
var targetFolder = target.createFolder(folderName);
copyFolder(subFolder, targetFolder);
}
}
form.html
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<script>
function callFolderDownload() {
document.getElementById('messages').innerHTML = 'Copying...';
google.script.run
.withSuccessHandler(onSuccess)
.withFailureHandler(onFailure)
.start();
}
function onSuccess() {
document.getElementById('messages').innerHTML = 'Success';
}
function onFailure(error)
{
document.getElementById('messages').innerHTML = error.message;
}
</script>
</head>
<body>
<div id="messages">Click the button to copy the folder.
</div>
<div>
<button type="button" onclick='callFolderDownload();' id="download">Copy</button>
</div>
</body>
</html>
You would need to break up your execution into various parts
You can't "stream" updates from the server process of copying a folder.
At the expense of making your execution slower, you can break it up into various phases, and at the end of each phase, update the client side with status. Remember that for every different stage you are having to:
- Make a call to Apps Script from client.
- Apps Script makes a separate call to Drive.
- Drive responds to Apps Script.
- Apps Script responds to client.
To get a message for each file copied, this needs to happen for each file. Depending on how many files you need to copy, this may not work. In that case, you would probably have to do it in batches. That is, maybe when you get the list of files back from the server, you can send 10 to the server to copy per request.
The key is that there has to be a separate call and response from server for each "real time update". These calls and responses need to be asynchronously programmed, that is, each stage needs to wait for previous stages to complete. This can result in the "pyramid of doom" which can be quite ugly and hard to understand:
google.script.run
.withSuccessHandler((targetId) => {
google.script.run
.withSuccessHandler((list) => {
newMessage("Starting to copy...");
list.forEach((item) => {
google.script.run
.withSuccessHandler(() => {
newMessage(item.name + " has been copied");
})
.copyItem(item.id, targetId);
});
})
.getFolderItems();
})
.createTargetFolder(targetName);
This can be made better with promises or the async await syntax, but in the interest of not making this answer too long, I have omitted it.
Below is an example of it working:
HTML
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<script>
// This is a utility function to append a new message to the html
function newMessage(message) {
let messageElement = document.getElementById("messages")
let sourceDiv = document.createElement("div")
sourceDiv.innerText = message
messageElement.append(sourceDiv)
}
function copyFolder() {
newMessage("Preparing to copy...")
let targetName = document.getElementById("newFolderName").value
console.log(targetName)
google.script.run
.withSuccessHandler(targetId => {
google.script.run
.withSuccessHandler(list => {
newMessage("Starting to copy...")
list.forEach(item => {
google.script.run
.withSuccessHandler(() => {
newMessage(item.name + " has been copied")
})
.copyItem(item.id, targetId)
})
})
.getFolderItems()
})
.createTargetFolder(targetName)
}
</script>
</head>
<body>
<div>
<label for="newFolderName">Type in Name of new folder that contents will be copied to</label>
<input id="newFolderName" type="text">
<button type="button" onclick='copyFolder();' id="download">Copy</button>
</div>
<div id="messages">
-
</div>
</body>
</html>
Apps Script
class FileToCopy {
constructor(id, name) {
this.id = id;
this.name = name;
}
}
function doGet() {
return HtmlService.createHtmlOutputFromFile('form');
}
function createTargetFolder(name){
Logger.log(name)
var target = DriveApp.createFolder(name);
return target.getId()
}
function getFolderItems() {
var sourceFolderId = "[SOURCE_FOLDER_ID]";
var source = DriveApp.getFolderById(sourceFolderId);
var files = source.getFiles();
let output = []
while (files.hasNext()) {
var file = files.next();
let id = file.getId();
let name = file.getName();
let newItem = new FileToCopy(id,name)
output.push(newItem)
}
return output;
}
function copyItem(id, target) {
let file = DriveApp.getFileById(id)
let folder = DriveApp.getFolderById(target)
file.makeCopy(folder);
}
Result
Reference
Client to Server Communications

Reusable Google doc Picker in google scripts - Picker Callback

Docu References:
Drive file picker v3
G Suite Dialogs: File Open Dialog
SO References:
Access data in Google App Script from spread sheet modal html form
How do I handle the call back using multiple Google File Picker
What to achieve?
In a Google Sheets script, I would like to define a Files Picker that returns the data of picked up files, provided that thereon, from another part of the scripts, the caller can receive that data.
Problem:
The file picker is launched as an html Modal dialog. After searching for a while, the only solution to get the data from the script that launched the picker is from the html script code:
set the callaback of the picker to a specific function: picker.setCallback(my_callback)
or use google.script.run.my_callback (i.e. from button Done for instance)
... provided that my_callback function defined in your script gets the data.
The problem with the above is that you cannot use the same picker for multiple purposes, because:
my_callback is fixed in the html script
my_callback cannot know for what purpose the picker was initially called (i.e. should it get the content?, should it give the information to some unknown caller?).
Once it gets the data, my_callback does not know what to do with it... unless my_callback is tied to only 1 caller; which does not seem correct, as that would require to have multiple html definitions for the picker, once per each reason you may invoke it, so it can call back to the proper function.
Any ideas?
global variables in scripts get re-initialized and cannot use PropertiesService to store values other than String (so no way to store the final picker_callback through a global var).
google.script.run does not offer calls by giving the name of the server-side function as String (reference) (which discards having a function to generate the picker_dialog.html by changing the callback function).
Sample Code
code.gs
function ui() {
return SpreadsheetApp.getUi();
}
function onOpen() {
ui().createMenu('ecoPortal Tools')
.addItem('Read a file', 'itemReadFile')
.addItem('Edit a file', 'itemEditFile')
.addToUi();
}
function itemReadFile() {
pickFile(readFile)
}
function itemEditFile() {
pickFile(editFile)
}
function readFile(data) {
/* do some stuff */
}
function editFile(data) {
/* do some stuff */
}
picker.gs:
function pickFile(callback) {
var html = HtmlService.createHtmlOutputFromFile('picker_dialog.html')
.setWidth(600)
.setHeight(425)
.setSandboxMode(HtmlService.SandboxMode.IFRAME);
// concept (discarded):
callback_set('picker', callback)
ui().showModalDialog(html, 'Select a file');
}
function getOAuthToken() {
DriveApp.getRootFolder();
return ScriptApp.getOAuthToken();
}
// picker callback hub
function pickerCallback(data) {
var callback = callback_get('picker');
callback_set('picker', null);
if (callback) callback.call(data);
}
picker_dialog.html
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="https://ssl.gstatic.com/docs/script/css/add-ons.css">
<script>
var DEVELOPER_KEY = '___PICKER_API_KEY_____';
var DIALOG_DIMENSIONS = {width: 600, height: 425};
var pickerApiLoaded = false;
// currently selected files data
var files_data = null;
/**
* Loads the Google Picker API.
*/
function onApiLoad() {
gapi.load('picker', {'callback': function() {
pickerApiLoaded = true;
}});
}
function getOAuthToken() {
console.log("going to call get auth token :)");
google.script.run.withSuccessHandler(createPicker)
.withFailureHandler(showError).getOAuthToken();
}
function createPicker(token) {
console.log("pickerApiLoadded", pickerApiLoaded);
console.log("token", token);
if (pickerApiLoaded && token) {
var picker = new google.picker.PickerBuilder()
.addView(google.picker.ViewId.DOCS)
.enableFeature(google.picker.Feature.NAV_HIDDEN)
.hideTitleBar()
.setOAuthToken(token)
.setDeveloperKey(DEVELOPER_KEY)
.setCallback(pickerCallback)
.setOrigin(google.script.host.origin)
.setSize(DIALOG_DIMENSIONS.width - 2,
DIALOG_DIMENSIONS.height - 2)
.build();
picker.setVisible(true);
} else {
showError('Unable to load the file picker.');
}
}
function pickerCallback(data) {
var action = data[google.picker.Response.ACTION];
if (action == google.picker.Action.PICKED) {
files_data = data;
var doc = data[google.picker.Response.DOCUMENTS][0];
var id = doc[google.picker.Document.ID];
var url = doc[google.picker.Document.URL];
var title = doc[google.picker.Document.NAME];
document.getElementById('result').innerHTML =
'<b>You chose:</b><br>Name: <a href="' + url + '">' + title +
'</a><br>ID: ' + id;
} else if (action == google.picker.Action.CANCEL) {
document.getElementById('result').innerHTML = 'Picker canceled.';
}
}
function showError(message) {
document.getElementById('result').innerHTML = 'Error: ' + message;
}
function closeIt() {
google.script.host.close();
}
function returnSelectedFilesData() {
google.script.run.withSuccessHandler(closeIt).pickerCallback(files_data);
}
</script>
</head>
<body>
<div>
<button onclick='getOAuthToken()'>Select a file</button>
<p id='result'></p>
<button onclick='returnSelectedFilesData()'>Done</button>
</div>
<script src="https://apis.google.com/js/api.js?onload=onApiLoad"></script>
</body>
</html>
picker.setCallback(my_callback)
Picker callback is different from:
or use google.script.run.my_callback
The former calls a function on the frontend html while the latter calls a function in the server.
my_callback cannot know for what purpose the picker was initially called
You can send a argument to the server:
google.script.run.my_callback("readFile");
On the server side(code.gs),
fuction my_callback(command){
if(command === "readFile") Logger.log("Picker called me to readFile");
}
google.script.run does not offer calls by giving the name of the server-side function as String
Not true. Dot is used to access members of a object. You can use bracket notation to access a member as a string:
google.script.run["my_callback"]();
EDITED BY Q.ASKER:
In your case, to pass the files_data to the server side:
google.script.run.withSuccessHandler(closeIt)[my_callback](files_data);
Now, for my_callback (String variable) to be set from server side, you need to push it using templates:
function pickFile(str_callback) {
var htmlTpl = HtmlService.createTemplateFromFile('picker_dialog.html');
// push variables
htmlTpl.str_callback = str_callback;
var htmlOut = htmlTpl.evaluate()
.setWidth(600)
.setHeight(425)
.setSandboxMode(HtmlService.SandboxMode.IFRAME);
ui().showModalDialog(htmlOut, 'Select a file');
}
The two unique changes that you need to make to your picker_dialog.html:
add printing scriptlet to set my_callback (<?= ... ?>)
use the google.script.run as mentioned
var my_callback = <?= str_callback? str_callback : 'defaultPickerCallbackToServer' ?>;
/* ... omitted code ... */
function returnSelectedFilesData() {
google.script.run.withSuccessHandler(closeDialog)[my_callback](files_data);
}
Now, when you call pickFile to open the frontend picker, you are able to set a different server callback that will receive the data with the file(s) chosen by the user.

How to pass a parameter to html?

I have a script that uses the file picker but I need to pass a specific parameter which is called userId and is kept as a global variable in the calling script. As the calls are asynchronous it seems I cannot access this parameter. Is there a way to access the parameter from the html file or pass this parameter to the html?
I might be mixing templated html and non templated.
Here is the calling code (initiated through a menu item in a spreadsheet):
function syncStudentsFile(userId, ss) {
scriptUser_(userId); // save userId
Logger.log('SRSConnect : syncStudentsFile : userId:'+userId); // userId is correct here
var ss = SpreadsheetApp.getActiveSpreadsheet();
var html = HtmlService.createHtmlOutputFromFile('PickerSync.html')
.setWidth(600).setHeight(425);
SpreadsheetApp.getUi().showModalDialog(html, 'Select a file');
}
function scriptUser_(userId) {
if (userId !== undefined)
sUserId = userId; // Global variable
try { return sUserId; } catch (e) { return undefined; }
}
function getOAuthToken() { // used by Picker
DriveApp.getRootFolder();
return ScriptApp.getOAuthToken();
}
Here is the html picker file:
<link rel="stylesheet" href="https://ssl.gstatic.com/docs/script/css/add-ons.css">
<script type="text/javascript">
var DEVELOPER_KEY = '..............';
var DIALOG_DIMENSIONS = {width: 600, height: 425};
var pickerApiLoaded = false;
/**
* Loads the Google Picker API.
*/
gapi.load('picker', {'callback': function() {
pickerApiLoaded = true;
}});
/**
* Gets the user's access token from the server-side script so that
* it can be passed to Picker. This technique keeps Picker from needing to
* show its own authorization dialog, but is only possible if the OAuth scope
* that Picker needs is available in Apps Script. Otherwise, your Picker code
* will need to declare its own OAuth scopes.
*/
function getOAuthToken() {
google.script.run.withSuccessHandler(createPicker)
.withFailureHandler(showError).getOAuthToken();
}
/**
* Creates a Picker that can access the user's spreadsheets. This function
* uses advanced options to hide the Picker's left navigation panel and
* default title bar.
*
* #param {string} token An OAuth 2.0 access token that lets Picker access the
* file type specified in the addView call.
*/
function createPicker(token) {
if (pickerApiLoaded && token) {
var uploadView = new google.picker.DocsUploadView();
var picker = new google.picker.PickerBuilder()
// Instruct Picker to display only spreadsheets in Drive. For other
// views, see https://developers.google.com/picker/docs/#otherviews
.addView(google.picker.ViewId.DOCS)
.addView(google.picker.ViewId.RECENTLY_PICKED)
.addView(uploadView)
.hideTitleBar()
.setOAuthToken(token)
.setDeveloperKey(DEVELOPER_KEY)
.setCallback(pickerCallback)
// Instruct Picker to fill the dialog, minus 2 pixels for the border.
.setSize(DIALOG_DIMENSIONS.width - 2,
DIALOG_DIMENSIONS.height - 2)
.build();
picker.setVisible(true);
} else {
showError('Unable to load the file picker.');
}
}
/**
* A callback function that extracts the chosen document's metadata from the
* response object. For details on the response object, see
* https://developers.google.com/picker/docs/result
*
* #param {object} data The response object.
*/
function pickerCallback(data) {
var action = data[google.picker.Response.ACTION];
if (action == google.picker.Action.PICKED) {
var doc = data[google.picker.Response.DOCUMENTS][0];
var id = doc[google.picker.Document.ID];
google.script.host.close();
// --------------> user global parameter sUserId set earlier
google.script.run.PickerSyncFile(sUserId, id);
} else if (action == google.picker.Action.CANCEL) {
google.script.host.close();
}
}
/**
* Displays an error message within the #result element.
*
* #param {string} message The error message to display.
*/
function showError(message) {
document.getElementById('result').innerHTML = 'Error: ' + message;
}
</script>
<div>
<script>getOAuthToken()</script>
<p id='result'></p>
<input type="button" value="Close" onclick="google.script.host.close()" />
</div>
Here is the picker code:
function pickerSyncFile(userId, id) {
Logger.log('userId:'+userId); // BUG: it is null
Logger.log('id:'+id); // id returned well from picker
// rest of code here but userId was is incorrect
}
The safest way is to pass the needed data to the HTML directly. If you use properties or cache service it can get complex or fail under multiple simultaneous users.
There are many techniques to pass an initial object from the server (.gs) to the client (.html).
Using HtmlTemplate, you may do:
//.gs file
function doGet() {
var htmlTemplate = HtmlService.createTemplateFromFile('template-client');
htmlTemplate.dataFromServerTemplate = { first: "hello", last: "world" };
var htmlOutput = htmlTemplate.evaluate().setSandboxMode(HtmlService.SandboxMode.IFRAME)
.setTitle('sample');
return htmlOutput;
}
and in your template-client.html file:
<!DOCTYPE html>
<script>
var data = <?!= JSON.stringify(dataFromServerTemplate) ?>; //Stores the data directly in the javascript code
// sample usage
function initialize() {
document.getElementById("myTitle").innerText = data.first + " - " + data.last;
//or use jquery: $("#myTitle").text(data.first + " - " + data.last);
}
// use onload or use jquery to call your initialization after the document loads
window.onload = initialize;
</script>
<html>
<body>
<H2 id="myTitle"></H2>
</body>
</html>
It is also possible to do it without using templating, by appending a hidden div to an HtmlOutput:
//.gs file:
function appendDataToHtmlOutput(data, htmlOutput, idData) {
if (!idData)
idData = "mydata_htmlservice";
// data is encoded after stringifying to guarantee a safe string that will never conflict with the html.
// downside: increases the storage size by about 30%. If that is a concern (when passing huge objects) you may use base94
// or even base128 encoding but that requires more code and can have issues, see http://stackoverflow.com/questions/6008047/why-dont-people-use-base128
var strAppend = "<div id='" + idData + "' style='display:none;'>" + Utilities.base64Encode(JSON.stringify(data)) + "</div>";
return htmlOutput.append(strAppend);
}
// sample usage:
function doGet() {
var htmlOutput = HtmlService.createHtmlOutputFromFile('html-sample')
.setSandboxMode(HtmlService.SandboxMode.IFRAME)
.setTitle('sample');
// data can be any (serializable) javascript object.
// if your data is a native value (like a single number) pass an object like {num:myNumber}
var data = { first: "hello", last: "world" };
// appendDataToHtmlOutput modifies the html and returns the same htmlOutput object
return appendDataToHtmlOutput(data, htmlOutput);
}
and in your output-client.html:
<!DOCTYPE html>
<script>
/**
* getDataFromHtml
*
* Inputs
* idData: optional. id for the data element. defaults to "mydata_htmlservice"
*
* Returns
* The stored data object
*/
function getDataFromHtml(idData) {
if (!idData)
idData = "mydata_htmlservice";
var dataEncoded = document.getElementById(idData).innerHTML;
var data = JSON.parse(atob(dataEncoded));
return data;
}
// sample usage of getDataFromHtml
function initialize() {
var data = getDataFromHtml();
document.getElementById("myTitle").innerText = data.first + " - " + data.last;
//or use jquery: $("#myTitle").text(data.first + " - " + data.last);
}
// use onload or use jquery to call your initialization after the document loads
window.onload = initialize;
</script>
<html>
<body>
<H2 id="myTitle"></H2>
</body>
</html>
Both methods are compared and better explained in this little github I made:
https://github.com/zmandel/htmlService-get-set-data
I often use HtmlService templates to push static values to the client.
index.html
<script>
var domain = "<?=domain?>";
</script>
code.gs
var ui = HtmlService.createTemplateFromFile('Sidebar');
ui.domain = domain;
return ui.evaluate().setSandboxMode(HtmlService.SandboxMode.IFRAME).setTitle(strings[lang][domain]);
In your code:
function scriptUser_(userId) {
if (userId !== undefined)
sUserId = userId; // Global variable
try { return sUserId; } catch (e) { return undefined; }
}
You are assigning a value to the global variable named sUserId. But, then when you try to retrieve it, nothing is there. Global variables loose their value as soon as the current instance of the code bring run is completed. Global variable don't persist their values.
You'll need to use the Properties Service to store the value. Or you could use the Cache service. If you want the value of the user id to expire after some time, use cache service.
By Appending to the HTML File, as shown below.
within Code.gs
function showDialog() {
var html = HtmlService.createHtmlOutputFromFile('html-sample')
.setSandboxMode(HtmlService.SandboxMode.IFRAME)
.setWidth(600)
.setHeight(425);
var data = "Hello World!";
var strAppend = "<div id='id_for_div' style='display:none;'>" + data + "</div>";
html.append(strAppend);
var title = "Demo";
SpreadsheetApp.getUi().showModalDialog(html, title); // or DocumentApp or SlidesApp or FormApp.
}
html-sample.html
<!DOCTYPE html>
<html>
<head>
<script>
function setText(text) {
var element = document.getElementById("myid");
element.innerHTML = text;
}
function getDataFromHtml() {
var id = "id_for_div";
var dataEncoded = document.getElementById(id).innerHTML;
setText(dataEncoded);
}
</script>
</head>
<body>
<h1 id="myid">Sample Text</h1>
<button onclick="getDataFromHtml()">Try Now!</button>
</body>
</html>
Click the 'Try Now!' Button and See the Magic!
This post provides the solution how to pass parameter to a templated html:
html = HtmlService.createTemplateFromFile('page2');
html.id = s1;
Then in page 2 use the the tag to print out the id value;
<div class="info" >
<span id="ID" name="ID"><?=id?></span>
</div>

How to change Bing Map code to Google Map code

Good day,
Does anyone have an idea of how to change a bing map to google map api code. I have found a very useful code on line that is coded using bing maps and would like to change it to work with google maps. if found the code here: http://blogs.msdn.com/b/crm/archive/2011/01/19/custom-charting-capabilities-in-microsoft-dynamics-crm-2011.aspx
Code below:
<html>
<head>
<title>Accounts on Bing Maps</title>
<script type="text/javascript" src="http://ecn.dev.virtualearth.net/mapcontrol/mapcontrol.ashx?v=6.3"></script>
<script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.4.2/jquery.min.js"></script>
<script type="text/javascript" src="ClientGlobalContext.js.aspx"></script>
<script type="text/javascript">
var map;
// Function to construct key-value pairs from a query string.
function getParametersFromQuery(query) {
var parametersDictionary = new Array();
var parameters = query.split('&');
for (var i = 0; i < parameters.length; i++) {
var keyAndValue = parameters[i].split('=');
parametersDictionary[unescape(keyAndValue[0])] = unescape(keyAndValue[1]);
}
return parametersDictionary;
}
// Function that makes a GET request to the CRM REST end-point, and invokes a callback with the results.
function retrieveFromCrmRestApi(url, callback) {
$.ajax({
type: "GET",
url: GetGlobalContext().getServerUrl() + "/XRMServices/2011/OrganizationData.svc" + url,
contentType: "application/json; charset=utf-8",
dataType: "json",
success: function (data) {
callback(data.d);
}
});
}
// Function that retrieves the corresponding CRM chart, and invokes the callback when successful.
function loadChartFromCrm(callback) {
var parameters = getParametersFromQuery(window.location.search.substring(1));
parameters = getParametersFromQuery(parameters["data"]);
var id = parameters["visid"].substr(1, 36);
var type = parameters["vistype"];
var url = (type == "1111" ? "/SavedQueryVisualizationSet" : "/UserQueryVisualizationSet")
+ "(guid'" + id + "')?$select=DataDescription,PresentationDescription";
retrieveFromCrmRestApi(url, callback);
}
var locations = new Array();
function plotAccountLocations(accounts) {
if (accounts.length > 0) {
var account = accounts.pop();
var address = account.Address1_City + ', ' + account.Address1_Country;
map.Find(null, address, null, null, 0, 1, false, false, false, false,
function (shapeLayer, results, places, moreResults, error) {
if (null != places && places.length > 0) {
var place = places[0];
var newShape = new VEShape(VEShapeType.Pushpin, place.LatLong);
newShape.SetTitle(account.Name);
newShape.SetDescription(address);
locations.push(newShape);
}
// When we have found (or not found) the current account,
// recursively call the same function to find the next one.
plotAccountLocations(accounts);
});
}
else {
var shapeLayer = new VEShapeLayer();
map.AddShapeLayer(shapeLayer);
shapeLayer.AddShape(locations);
}
}
function loadAccountsFromCrm(dataDescription) {
var url = "/AccountSet?$select=Address1_Country,Address1_City,Name";
if (null != dataDescription) {
// Filter accounts based on country specified in data description.
url += "&$filter=Address1_Country eq '" + dataDescription + "'";
}
retrieveFromCrmRestApi(url,
function (data) {
var results = data["results"];
var accounts = new Array();
for (resultKey in results) {
accounts.push(results[resultKey]);
}
// Once accounts are retrieved from CRM Server, plot their locations on map.
plotAccountLocations(accounts);
}
);
}
function getMap(presentationDescription) {
// Set center and zoom defaults.
var center = null;
var zoom = 4;
if (null != presentationDescription) {
// Calculate map-center and zoom from the presentation description.
var arguments = presentationDescription.split(',');
if (arguments.length > 1) {
center = new VELatLong(arguments[0], arguments[1]);
}
if (arguments.length > 2) {
zoom = arguments[2];
}
}
map = new VEMap("map");
map.LoadMap(center, zoom, VEMapStyle.Road, true, VEMapMode.Mode2D, false, 0);
window.onresize = function (event) { map.Resize(document.body.clientWidth, document.body.clientHeight); };
window.onresize(null);
}
function loadMap() {
// First, get the chart object from CRM Server.
loadChartFromCrm(
function (chart) {
// Once we have retrieved the chart, format the map based on the chart's presentation description.
getMap(chart.PresentationDescription);
// Get Accounts from CRM Server based on the chart's data description, and plot them on the map.
loadAccountsFromCrm(chart.DataDescription);
}
);
}
</script>
</head>
<body onload="loadMap()">
<div id="map"></div>
</body>
</html>
There is no easy out of the box solution for what you are looking for.
Google API has many differences. For example, the Google API leaves keeping track of map children to the programmer (you). There is no means of looping through the map controls like there is with the Bing API. This means that your solution for saving the map content and re-displaying it will be a little different.
Although, since both API's are done via javascript, all you need to do is convert your functionality according to their documentation;
Geocoding Sample
Simple Map Initialization
Adding Shapes
Adding Markers (pushpin)