Update value in a spreadsheet sidebar when next row is selected in table - google-apps-script

To facilitate the annotation of audio files in a Google spreadsheet, I'd like to implement an audio player in the sidebar which automatically plays the audio file mentioned as URL in the row of a table. After listening and entering some date in this row, I'd like to move to the next row and do the same. Thus, the URL to the audio file should be updated whenever I select a new row and the whole process should be fast, too, in order to listen quickly to one sound file after the other.
I've experimented with the solution mentioned in this SO post, but this solution is relying on a poll function with a time interval, which is impractical for me as it periodically is updating the sidebar. Crucial for me would be to update the content of the sidebar only once.
Code.gs
var SIDEBAR_TITLE = 'Opnam lauschteren';
/**
* Adds a custom menu with items to show the sidebar and dialog.
*
* #param {Object} e The event parameter for a simple onOpen trigger.
*/
function onOpen(e) {
SpreadsheetApp.getUi()
.createAddonMenu()
.addItem('Opname lauschteren', 'showSidebar')
.addToUi();
}
/**
* Runs when the add-on is installed; calls onOpen() to ensure menu creation and
* any other initializion work is done immediately.
*
* #param {Object} e The event parameter for a simple onInstall trigger.
*/
function onInstall(e) {
onOpen(e);
}
/**
* Opens a sidebar. The sidebar structure is described in the Sidebar.html
* project file.
*/
function showSidebar() {
var ui = HtmlService.createTemplateFromFile('Sidebar')
.evaluate()
.setSandboxMode(HtmlService.SandboxMode.IFRAME)
.setTitle(SIDEBAR_TITLE);
SpreadsheetApp.getUi().showSidebar(ui);
}
function getValues() {
var app = SpreadsheetApp;
var value = app.getActiveSpreadsheet().getActiveSheet().getActiveCell().getValue();
Logger.log(value);
return value;
}
function getRecord() {
// Retrieve and return the information requested by the sidebar.
var sheet = SpreadsheetApp.getActiveSheet();
var data = sheet.getDataRange().getValues();
var headers = data[0];
var rowNum = sheet.getActiveCell().getRow();
if (rowNum > data.length) return [];
var record = [];
for (var col=0;col<headers.length;col++) {
var cellval = data[rowNum-1][col];
// Dates must be passed as strings - use a fixed format for now
if (typeof cellval == "object") {
cellval = Utilities.formatDate(cellval, Session.getScriptTimeZone() , "M/d/yyyy");
}
// TODO: Format all cell values using SheetConverter library
record.push({ heading: headers[col],cellval:cellval });
}
Logger.log(record);
return record;
}
Sidebar.html
<!-- Use a templated HTML printing scriptlet to import common stylesheet. -->
<?!= HtmlService.createHtmlOutputFromFile('Stylesheet').getContent(); ?>
<!-- Below is the HTML code that defines the sidebar element structure. -->
<div class="sidebar branding-below">
<!-- The div-table class is used to make a group of divs behave like a table. -->
<div class="block div-table" id="sidebar-record-block">
</div>
<div class="block" id="sidebar-button-bar">
</div>
<div id="sidebar-status"></div>
<!-- Use a templated HTML printing scriptlet to import JavaScript. -->
<?!= HtmlService.createHtmlOutputFromFile('SidebarJavaScript').getContent(); ?>
</div>
<!-- Enter sidebar bottom-branding below. -->
<div class="sidebar bottom">
<span class="gray branding-text">PG</span>
</div>
SidebarJavaScript.html
<script src="//ajax.googleapis.com/ajax/libs/jquery/2.1.4/jquery.min.js"></script>
<script>
/**
* Run initializations on sidebar load.
*/
$(function() {
// Assign handler functions to sidebar elements here, if needed.
// Call the server here to retrieve any information needed to build
// the dialog, if necessary.
// Start polling for updates
poll();
});
/**
* Poll a server-side function at the given interval, to have
* results passed to a successHandler callback.
*
* https://stackoverflow.com/a/24773178/1677912
*
* #param {Number} interval (optional) Time in ms between polls.
* Default is 2s (2000ms)
*/
function poll(interval) {
interval = interval || 3000;
setTimeout(function() {
google.script.run
.withSuccessHandler(showRecord)
.withFailureHandler(
function(msg, element) {
showStatus(msg, $('#button-bar'));
element.disabled = false;
})
.getRecord();
}, interval);
};
/**
* Callback function to display a "record", or row of the spreadsheet.
*
* #param {object[]} Array of field headings & cell values
*/
function showRecord(record) {
if (record.length) {
for (var i = 2; i <= 2; i++) {
// build field name on the fly, formatted field-1234
var str = '' + i;
var fieldId = 'field-' + ('0000' + str).substring(str.length)
// If this field # doesn't already exist on the page, create it
if (!$('#'+fieldId).length) {
var newField = $($.parseHTML('<div id="'+fieldId+'"></div>'));
$('#sidebar-record-block').append(newField);
}
// Replace content of the field div with new record
$('#'+fieldId).replaceWith('<div id="'+fieldId+'" class="div-table-row"></div>');
$('#'+fieldId).append($('<div class="div-table-th">' + record[i].heading + '</div>'))
.append('<audio id="player" controls > <source src=' + record[i].cellval + ' type=audio/wav > Your browser does not support the audio element. </audio>');
}
}
// TODO: hide any existing fields that are beyond the current record length
//Setup the next poll
poll();
}
/**
* Displays the given status message in the sidebar.
*
* #param {String} msg The status message to display.
* #param {String} classId The message type (class id) that the message
* should be displayed as.
*/
function showStatus(msg, classId) {
$('#sidebar-status').removeClass().html(msg);
if (classId) {
$('#sidebar-status').addClass(classId);
}
}
</script>
A reproducible example is accessible here; Add-ons > 'play audio' (Google account necessary).
I am struggling finding a method to trigger the update of the sidebar only once and only when a new row is selected. The use of a sidebar is not mandatory, rather another solution, e.g. with a automatically updated 'Play' button, would be helpful, too.

I made some small changes to the example code you provided so that the sidebar does not update periodically following the time interval.
Basically, I've used PropertiesService to store the row that is selected. The idea is that the script checks whether the currently selected row and the previously selected row (the one selected last time getRecord was called, that is, during last interval) are the same. If they are the same, there hasn't been a row selection change, which means the audio in the sidebar doesn't need updating.
So it only updates if the selected row changes, which is, I think, the main issue you are having.
To achieve this, your code would have to be modified in the following way (look at inline comments for details on the changes):
getRecord()
function getRecord() {
var scriptProperties = PropertiesService.getScriptProperties();
var sheet = SpreadsheetApp.getActiveSheet();
var data = sheet.getDataRange().getValues();
var headers = data[0];
var rowNum = sheet.getActiveCell().getRow(); // Get currently selected row
var oldRowNum = scriptProperties.getProperty("selectedRow"); // Get previously selected row
if(rowNum == oldRowNum) { // Check if the was a row selection change
// Function returns the string "unchanged"
return "unchanged";
}
scriptProperties.setProperty("selectedRow", rowNum); // Update row index
if (rowNum > data.length) return [];
var record = [];
for (var col=0;col<headers.length;col++) {
var cellval = data[rowNum-1][col];
if (typeof cellval == "object") {
cellval = Utilities.formatDate(cellval, Session.getScriptTimeZone() , "M/d/yyyy");
}
record.push({ heading: headers[col],cellval:cellval });
}
return record;
}
Depending on whether there was a selection change, getRecord returns:
a record array, if the selected row is different.
the string "unchanged", if the selected row is the same. Probably this is not the most elegant way to handle this, but you get the idea.
Then, showRecord(record) gets this returned value. If this value is the string "unchanged", it won't update the sidebar:
showRecord(record)
function showRecord(record) {
// Checks whether returned value is `"unchanged"` (this means the row selected is the same one as before)
if (record != "unchanged" && record.length) {
for (var i = 2; i <= 2; i++) {
// build field name on the fly, formatted field-1234
var str = '' + i;
var fieldId = 'field-' + ('0000' + str).substring(str.length)
// If this field # doesn't already exist on the page, create it
if (!$('#'+fieldId).length) {
var newField = $($.parseHTML('<div id="'+fieldId+'"></div>'));
$('#sidebar-record-block').append(newField);
}
// Replace content of the field div with new record
$('#'+fieldId).replaceWith('<div id="'+fieldId+'" class="div-table-row"></div>');
$('#'+fieldId).append($('<div class="div-table-th">' + record[i].heading + '</div>'))
.append('<audio id="player" controls autoplay> <source src=' + record[i].cellval + ' type=audio/wav > Your browser does not support the audio element. </audio>');
}
}
// TODO: hide any existing fields that are beyond the current record length
//Setup the next poll
poll();
}
I also added the autoplay attribute in this line:
.append('<audio id="player" controls> <source src=' + record[i].cellval + ' type=audio/wav > Your browser does not support the audio element. </audio>')
So that the audio plays automatically when you select a new row, without having to click the play button.
Finally, I changed the poll interval to 500, so that you don't have to wait so much for the new audio to play. Anyway you can edit this to whatever suits you best:
interval = interval || 500;
I didn't modify the rest of the script, even though it can probably be improved owing to the fact that it was mainly written for a different issue.
I hope this is of any help.

Playing My Music
I added a play this button to each of my playlist selections. Perhaps this will help you to accomplish what you wish.
code.gs:
function onOpen() {
SpreadsheetApp.getUi().createMenu('My Music')
.addItem('Launch Music', 'launchMusicDialog')
.addToUi();
}
function convMediaToDataUri(filename){
var filename=filename || "You Make Loving Fun.mp3";//this was my debug song
var folder=DriveApp.getFolderById("Music Folder Id");
var files=folder.getFilesByName(filename);
var n=0;
while(files.hasNext()) {
var file=files.next();
n++;
}
if(n==1) {
var blob=file.getBlob();
var b64DataUri='data:' + blob.getContentType() + ';base64,' + Utilities.base64Encode(blob.getBytes());
Logger.log(b64DataUri)
var fObj={filename:file.getName(),uri:b64DataUri}
return fObj;
}
throw("Multiple Files with same name.");
return null;
}
function launchMusicDialog() {
var userInterface=HtmlService.createHtmlOutputFromFile('music1');
SpreadsheetApp.getUi().showModelessDialog(userInterface, 'Music');
}
function doGet() {
return HtmlService.createHtmlOutputFromFile('music1').addMetaTag('viewport', 'width=device-width, initial-scale=1');
}
function getPlaylist() {
var ss=SpreadsheetApp.getActive();
var sh=ss.getSheetByName('MusicList');
var rg=sh.getRange(2,1,sh.getLastRow()-1,sh.getLastColumn());
var vA=rg.getValues();
var pl=[];
var idx=0;
var html='<style>th,td{border:1px solid black;}</style><table><tr><th>Index</th><th>Item</th><th>FileName</th><th> </th></tr>';
for(var i=0;i<vA.length;i++) {
if(vA[i][4]) {
pl.push(vA[i][1]);
html+=Utilities.formatString('<tr><td>%s</td><td>%s</td><td>%s</td><td><input type="button" value="Play This" onClick="playThis(%s)" /></td></tr>',idx,vA[i][0],vA[i][1],idx++);
}
}
html+='</table>';
return {playlist:pl,html:html};
}
music1.html
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<link rel="stylesheet" href="//code.jquery.com/ui/1.12.1/themes/base/jquery-ui.css">
<script src="https://code.jquery.com/ui/1.12.1/jquery-ui.js"></script>
<style>
label{margin:2px 10px;}
</style>
</head>
<script>
var selectionList=["BarbaraAnn.mp3","Don't Let Me Come Home a Stranger.mp3"];
var gVolume=0.2;
var index=0;
$(function(){
document.getElementById('msg').innerHTML="Loading Playlist";
google.script.run
.withSuccessHandler(function(Obj){
selectionList=Obj.playlist;
console.log(Obj.playlist);
document.getElementById('list').innerHTML=Obj.html;
google.script.run
.withSuccessHandler(function(fObj){
$('#audio1').attr('src',fObj.uri);
var audio=document.getElementById("audio1");
audio.volume=gVolume;
audio.onended=function() {
document.getElementById('status').innerHTML='Ended...';
playnext();
}
var msg=document.getElementById('msg');
msg.innerHTML="Click play to begin playlist. Additional selections will begin automatically";
audio.onplay=function() {
document.getElementById('msg').innerHTML='Playing: ' + selectionList[index-1];
document.getElementById('status').innerHTML='Playing...';
document.getElementById('skipbtn').disabled=false;
}
audio.onvolumechange=function(){
gVolume=audio.volume;
}
})
.convMediaToDataUri(selectionList[index++]);
})
.getPlaylist();
});
function playnext() {
if(index<selectionList.length) {
document.getElementById('status').innerHTML='Loading...';
document.getElementById('msg').innerHTML='Next Selection: ' + selectionList[index];
google.script.run
.withSuccessHandler(function(fObj){
$('#audio1').attr('src',fObj.uri);
var audio=document.getElementById('audio1');
audio.volume=gVolume;
audio.play();
})
.convMediaToDataUri(selectionList[index++]);
}else{
document.getElementById('status').innerHTML='Playlist Complete';
document.getElementById('msg').innerHTML='';
document.getElementById('cntrls').innerHTML='<input type="button" value="Replay Playlist" onClick="replayPlaylist()" />';
}
}
function replayPlaylist() {
index=0;
document.getElementById('cntrls').innerHTML='';
playnext();
}
function skip() {
var audio=document.getElementById('audio1');
document.getElementById('skipbtn').disabled=true;
audio.pause();
playnext();
}
function playThis(idx) {
index=idx;
var audio=document.getElementById('audio1');
//audio.pause();
playnext();
}
</script>
<body>
<div id="msg"></div>
<audio controls id="audio1" src=""></audio><br />
<div id="status"></div>
<div><input type="button" id="skipbtn" value="Skip" onClick="skip()" disabled /></div>
<div id="cntrls"></div>
<div id="list"></div>
</body>
</html>
Admittedly, the transition is a little rough but I didn't put that much effort into the modification so perhaps you can smooth it out a little. Just run launchMusicDiaog() to get it going. There's also a doGet() in there for the webapp.

Related

Google Docs API - complete documentation (hyperlink issue)

I hope everyone is in good health. This post is my continue of my previous post
My main goal
So main goal was to get the hyperlink and change it the text linked with it. I initially used code from this post and modified it to change the text of first hyperlink. Here is my modified code to change the text of first hyperlink.
function onOpen() {
const ui = DocumentApp.getUi();
ui.createMenu('What to do?')
.addItem('HyperLink Modifier', 'findAndReplacetext')
.addToUi();
}
/**
* Get an array of all LinkUrls in the document. The function is
* recursive, and if no element is provided, it will default to
* the active document's Body element.
*
* #param element The document element to operate on.
* .
* #returns {Array} Array of objects, vis
* {element,
* startOffset,
* endOffsetInclusive,
* url}
*/
function getAllLinks(element) {
var links = [];
element = element || DocumentApp.getActiveDocument().getBody();
if (element.getType() === DocumentApp.ElementType.TEXT) {
var textObj = element.editAsText();
var text = element.getText();
var inUrl = false;
for (var ch=0; ch < text.length; ch++) {
var url = textObj.getLinkUrl(ch);
if (url != null) {
if (!inUrl) {
// We are now!
inUrl = true;
var curUrl = {};
curUrl.element = element;
curUrl.url = String( url ); // grab a copy
curUrl.startOffset = ch;
}
else {
curUrl.endOffsetInclusive = ch;
}
}
else {
if (inUrl) {
// Not any more, we're not.
inUrl = false;
links.push(curUrl); // add to links
curUrl = {};
}
}
}
if (inUrl) {
// in case the link ends on the same char that the element does
links.push(curUrl);
}
}
else {
var numChildren = element.getNumChildren();
for (var i=0; i<numChildren; i++) {
links = links.concat(getAllLinks(element.getChild(i)));
}
}
return links;
}
/**
* Replace all or part of UrlLinks in the document.
*
* #param {String} searchPattern the regex pattern to search for
* #param {String} replacement the text to use as replacement
*
* #returns {Number} number of Urls changed
*/
function findAndReplacetext() {
var links = getAllLinks();
while(links.length > 0){
var link = links[0];
var paragraph = link.element.getText();
var linkText = paragraph.substring(link.startOffset, link.endOffsetInclusive+1);
var newlinkText = `(${linkText})[${link.url}]`
link.element.deleteText(link.startOffset, link.endOffsetInclusive);
link.element.insertText(link.startOffset, newlinkText);
links = getAllLinks();
}
}
String.prototype.betterReplace = function(search, replace, position) {
if (this.length > position) {
return this.slice(0, position) + this.slice(position).replace(search, replace);
}
return this;
}
Note: I used insertText and deleteText functions to update the text value of hyperlink.
My problem with above code
Now the problem was that this code was running too slow. I thought may be it was because I was running the script every-time I needed to search for next hyperlink, So maybe I can break the loop and only get the first hyperlink each time. Then from my previous post the guy gave me a solution to break loop and only get the first hyperlink but when I tried the new code unfortunately it was still slow. In that post he also proposed me a new method by using Google Docs API, I tried using that it was was super fast. Here is the code using Google Docs API
function myFunction() {
const doc = DocumentApp.getActiveDocument();
const res = Docs.Documents.get(doc.getId()).body.content.reduce((ar, {paragraph}) => {
if (paragraph && paragraph.elements) {
paragraph.elements.forEach(({textRun}) => {
if (textRun && textRun.textStyle && textRun.textStyle.link) {
ar.push({text: textRun.content, url: textRun.textStyle.link.url});
}
});
}
return ar;
}, []);
console.log(res) // You can retrieve 1st link and test by console.log(res[0]).
}
My new problem
I liked the new code but I am stuck again at this point as I am unable to find how can I change the text associated with the hyperlink. I tried using the functions setContent and setUrl but they don't seem to work. Also I am unable to find the documentation for these functions on main documentation of this API. I did find I reference for previously mentioned functions here but they are not available for appscript. Here is the sample document I am working on
https://docs.google.com/document/d/1eRvnR2NCdsO94C5nqly4nRXCttNziGhwgR99jElcJ_I/edit?usp=sharing
End note:
I hope I was able to completly convey my message and all the details assosiated with it. If not kindly don't be mad at me, I am still in learning process and my English skills are pretty weak. Anyway if you want any other data let me know in the comments and Thanks for giving your time I really appreciate that.
In order to remove all the hyperlink from your document, you can do the following:
First, retrieve the start and end indexes of these hyperlinks. This can be done by calling documents.get, iterate through all elements in the body content, checking which ones are paragraphs, iterating through the corresponding TextRun, and checking which TextRuns contain a TextStyle with a link property. All this is already done in the code you provided in your question.
Next, for all TextRuns that include a link, retrieve their startIndex and endIndex.
Using these retrieved indexes, call batchUpdate to make an UpdateTextStyleRequest. You want to remove the link property between each pair of indexes, and for that you would just need to set fields to link (in order to specify which properties you want to update) and don't set a link property in the textStyle property you provide in the request since, as the docs for TextStyle say:
link: If unset, there is no link.
Code sample:
function removeHyperlinks() {
const doc = DocumentApp.getActiveDocument();
const hyperlinkIndexes = Docs.Documents.get(doc.getId()).body.content.reduce((ar, {paragraph}) => {
if (paragraph && paragraph.elements) {
paragraph.elements.forEach(element => {
const textRun = element.textRun;
if (textRun && textRun.textStyle && textRun.textStyle.link) {
ar.push({startIndex: element.startIndex, endIndex: element.endIndex });
}
});
}
return ar;
}, []);
hyperlinkIndexes.forEach(hyperlinkIndex => {
const resourceUpdateStyle = {
requests: [
{
updateTextStyle: {
textStyle: {},
fields: "link",
range: {
startIndex: hyperlinkIndex.startIndex,
endIndex: hyperlinkIndex.endIndex
}
}
}
]
}
Docs.Documents.batchUpdate(resourceUpdateStyle, doc.getId());
});
}

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.

Google Calendar Authorization Apps Script - Standalone Web App

I am new to Apps Script and Web Development. I thought it would be nice to make a simple app to get started.
Goal: Display the future events of the user.
Problem: I am stuck on getting user authorization. Currently, the script is displaying my events. Instead, I want the script to display the user's (who is accessing the web app) events.
I found this sample from the documentation. This function gets the list of events of the user. https://developers.google.com/calendar/quickstart/apps-script
Then I wrote a basic index.html file to display the string populated by this above function to the user.
index.html
<!DOCTYPE html>
<html>
<head>
<base target="_top">
</head>
<body>
<script>
function getEventsOnClick() {
google.script.run.withSuccessHandler(changeDisplay).listAllEvents();
}
function changeDisplay(display) {
var div = document.getElementById('output');
div.innerHTML = display;
}
</script>
<div id="output"> Hello! </div>
<button onclick="getEventsOnClick()">Run Function</button>
</body>
</html>
code.gs
function doGet() {
return HtmlService.createHtmlOutputFromFile('Index');
}
function listAllEvents() {
var calendarId = 'primary';
var now = new Date();
var display = ""
var events = Calendar.Events.list(calendarId, {
timeMin: now.toISOString(),
maxResults: 2500,
});
if (events.items && events.items.length > 0) {
for (var i = 0; i < events.items.length; i++) {
var event = events.items[i];
if (event.start.date) {
// All-day event.
var start = new Date(event.start.date);
var end = new Date(event.end.date);
display = display + 'Start: ' + start.toLocaleDateString() + '; End: ' + end.toLocaleDateString() + ". ";
} else {
var start = new Date(event.start.dateTime);
var end = new Date(event.end.dateTime);
display = display + 'Start: ' + start.toLocaleString() + '; End: ' + end.toLocaleString() + ". ";
}
}
} else {
display = 'No events found.';
}
Logger.log("%s", display)
return display
}
Again, nothing is wrong with the above code. It does display events as expected. The problem is that it is displaying my events rather than the user. So, if I give a user URL for the app, then I want this app to request authorization and display their event. How would I do that?
Thanks!
When you deploy the app, make sure you choose to execute as the user rather than as yourself. (as yourself is the default).

Google Sheets Time Tracker: Update #1

I am trying to create a time sheet based on the video here. However, in the video, he sets it up so that it is built for two users, whereas mine is only going to be for one user.
When the user presses the button (really a drawing) to clock in, I want the time to be recorded in cell B2 and the status (in) to be recorded in E2. Just the same, when the user presses the button (another drawing) to clock out, I want the time to be recorded in C2 and the status (Out) to still be recorded in E2. Column D will tell the duration of time passed, which will be used to determine weekly pay.
Here's the script where the "Clock In" button is assigned the "In" Function and the "Clock Out" button is assigned the "Out" Function:
function setValue(cellName, Value) {
SpreadsheetApp.getActiveSpreadsheet().getRange(cellName).setValue(Value);
}
function getValue(cellName) {
return SpreadsheetApp.getActiveSpreadsheet().getRange(cellName).getValue();
}
function getNextRowIn() {
return SpreadsheetApp.getActiveSpreadsheet().getLastRow() - 6;
}
function getNextRowOut() {
return SpreadsheetApp.getActiveSpreadsheet().getLastRow() - 6;
}
function addRecordIn(b) {
var row = getNextRowIn ();
setValue('B' + row, b);
}
function addRecordOut(c) {
var row = getNextRowOut ();
setValue('C' + row, c);
}
function In() {
setValue('E2', 'In');
addRecordIn(new Date());
}
function Out() {
setValue('E2', 'Out');
addRecordOut(new Date());
}
What do I need to do to make this code work out so that each time a user presses "Clock In" it is recorded sequentially in column B, starting at B2. Same for Clock Out in Column C, starting at C2?
Here is a picture of the sheet I'm working with:
View it on Imgur.
Here's the way I would do it.
timerecord.html:
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<script src="//ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js"></script>
<script>
$(function() {
});
function punchIn(){
google.script.run.punchIn();
}
function punchOut(){
google.script.run.punchOut();
}
console.log('My Code');
</script>
</head>
<body>
<input type="button" value="Punch In" onClick="punchIn()" />
<input type="button" value="Punch Out" onClick="punchOut()" />
</body>
</html>
code.gs:
function onOpen(){
SpreadsheetApp.getUi().createMenu('My Tools')
.addItem('Time Clock', 'showTimeTrackerSidebar')
.addToUi();
}
function punchIn() {
SpreadsheetApp.getActive().getSheetByName('Time Record').appendRow([Utilities.formatDate(new Date(), Session.getScriptTimeZone(), "MMM dd yyyy HH:mm:ss")])
}
function punchOut(){
var ss=SpreadsheetApp.getActive()
var sh=ss.getSheetByName('Time Record');
var rg=sh.getDataRange();
var vA=rg.getValues();
var lr=vA[vA.length-1];
var outdt=Utilities.formatDate(new Date(),Session.getScriptTimeZone(),"MMM dd yyyy HH:mm:ss");
vA[vA.length-1][1]=outdt;
vA[vA.length-1][2]=calcTimeDifference(vA[vA.length-1][0],outdt);
rg.setValues(vA);
}
function showTimeTrackerSidebar(){
var ui=HtmlService.createHtmlOutputFromFile('timerecord');
SpreadsheetApp.getUi().showSidebar(ui);
}
function calcTimeDifference(Start,End)
{
if(Start && End)
{
var hour=1000*60*60;
var t1=new Date(Start).valueOf();
var t2=new Date(End).valueOf();
var d=t2-t1;
var hours=d/hour;
return hours
}
else
{
return 'Invalid Inputs';
}
}
This is what the screen looks like:
This view shows the menu
Instead of building psuedo buttons with images I just used the sidebar and used the standard <input type="button" value="Punch Out" onClick="punchOut();" /> This is a lot easier to extend to other features. You'll need to take a look at the google.script.run feature here. You'll also need to run the onOpen once to get the menu updated with the siderbar function.
This will append a new datetime with punchIn and punchOut with add a Datetime and calculate difference on the last row.

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>