I have a Google Sheet that does dynamic grouping with a script. I am looking for a function that gets rid of all those Groups in the sheet again.
Similar to expandAllColumnGroups, I would like to have a function called removeAllColumnGroups - but it seems there is no such function available.
My current approach is very slow and cumbersome. I did quite some research but could not even find a way to get all the columnGroups or at least the start-column-IDs in a sheet, so I iterate over every column and literally try to remove the group if there is one, as there is no way to tell if a group exits. Unfortunately for about 90 columns this takes ages (minutes, really)...
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sh = ss.getActiveSheet();
//remove all column-groups
var group = null;
var maxCol = sh.getMaxColumns();
for(var c = 1; c <= maxCol; c++) {
try {
group = sh.getColumnGroup(c, 1); // Try to get group
group.remove(); // Remove the group
} catch (e) {
//Logger.log("No Group at ColIndex "+c);
}
}
Any ideas would be really appreciated. Thanks in advance!
Expanding on my comment re: use of the Google Sheets REST API to access and modify row/column groupings:
Row/Column groups are metadata associated with a Sheet resource, and as such can be obtained for all sheets in a workbook with a single HTTP request to spreadsheets.get, with the appropriate fields specification:
GET https://sheets.googleapis.com/v4/spreadsheets/{YOUR_SPREADSHEET_ID}?fields=sheets(columnGroups%2Cproperties(sheetId%2Ctitle)%2CrowGroups)&key={YOUR_API_KEY}
Demo link
The above request returns an object with a sheets property, which is an array of objects (1 per sheet in the spreadsheet) having 3 properties: rowGroups, columnGroups, and properties. The group properties are arrays of DimensionGroup objects, while the properties object contains the sheet's gridId/sheetId, which you need for identifying the sheet in various API requests, and its name (which may be useful for your own script logic).
To delete every row/column group, you need to issue as many DeleteDimensionGroupRequests to do so as the maximum depth returned in your query's groups. If you do not specify the indices of the DimensionRange in your request, this is interpreted as the whole range of the spreadsheet (all rows / all columns, depending on direction).
An example request (requires OAuth authentication, not just API key):
POST https://sheets.googleapis.com/v4/spreadsheets/{YOUR SPREADSHEET ID}:batchUpdate?fields=replies%2FdeleteDimensionGroup
{
"requests": [
{
"deleteDimensionGroup": {
"range": {
"sheetId": "{SHEET 1 ID}",
"dimension": "COLUMNS"
}
}
},
{
"deleteDimensionGroup": {
"range": {
"sheetId": "{SHEET 2 ID}"
"dimension": "COLUMNS",
}
}
},
...
]
}
Demo link
Each delete request has a reply response, and that response will be very similar to the initial response you got for the row/column groups from the initial query. If you knew the gridIds beforehand, you could forgo the initial query and use a while loop to keep sending delete requests while the response contains a dimension group.
To use these methods with Google Apps Script, you can either use UrlFetchApp with raw URL resources, or take advantage of the available "advanced service" client library Sheets (which must first be enabled). Both methods require you to enable use of the Sheets API from your script's Google Cloud Platform project page.
An example using the enabled client library Sheets:
function removeAllGroups() {
const wb = SpreadsheetApp.getActive(),
wbId = wb.getId();
const initial = Sheets.Spreadsheets.get(wbId, {
fields: "sheets(columnGroups,properties(sheetId,title),rowGroups)"
});
// Determine the maximum depth of row & column groups on each sheet in the workbook.
const maxDepths = {row: {}, col: {}};
initial.sheets.forEach(function (s) {
// if (s.properties.title ... (could write logic to do this only for certain sheets)
var sId = s.properties.sheetId;
if (s.columnGroups && s.columnGroups.length)
maxDepths.col[sId] = s.columnGroups.reduce(dgMaxDepth_, 0);
if (s.rowGroups && s.rowGroups.length)
maxDepths.row[sId] = s.rowGroups.reduce(dgMaxDepth_, 0);
});
// Add all delete requests to an array
const rqs = [];
for (var rqType in maxDepths) {
for (var sheetId in maxDepths[rqType]) {
addDeleteDGRequests_(rqs, rqType, sheetId, maxDepths[rqType][sheetId]);
}
}
// Send all requests.
if (rqs.length) {
const replies = Sheets.Spreadsheets.batchUpdate({requests: rqs}, wbId);
console.log({message: "Batch response", response: replies});
}
}
// Callback for Array#reduce
function dgMaxDepth_(val, dg, i, allDGs) {
return Math.max(val, dg.depth);
}
function addDeleteDGRequests_(requests, rqType, sheetId, num) {
const dim = rqType === "col" ? "COLUMNS" : "ROWS";
while (num > 0) {
var rq = {
deleteDimensionGroup: {
range: { sheetId: sheetId,
dimension: dim }
}
};
requests.push(rq);
--num;
}
}
Resources:
Google APIs Explorer - Sheets API
Google Sheets REST API
Enable Advanced Services
Array#reduce
Array#forEach
Here's a simple solution.
function removeAllGroupsFromSheet() {
let sheet = SpreadsheetApp.getActiveSheet();
let lastRow = sheet.getDataRange().getLastRow();
for (let row = 1; row < lastRow; row++) {
let depth = sheet.getRowGroupDepth(row);
if (depth < 1) continue;
sheet.getRowGroup(row, depth).remove();
}
}
Related
I am trying to create a function (ultimately a menu button) that automatically resizes all the charts in a Sheet to a defined render width. I want to keep the other chart options (which might have changed from their default values before running this function) intact.
My code so far:
function resizeCharts() {
var newWidth = 1000;
var ss = SpreadsheetApp.getActiveSpreadsheet();
var currentSheet = ss.getActiveSheet();
var sheetCharts = currentSheet.getCharts();
for (var i = 0; i < sheetCharts.length; i++) {
var modifiedChart = sheetCharts[i].modify();
modifiedChart = modifiedChart.setOption('width', newWidth).build();
currentSheet.updateChart(modifiedChart);
}
}
This code will adjust the width of the chart, but it changes the other options (e.g. series colors, axis formats), which I don't want.
Is there a way to preserve all the existing options of a chart except for the one I want to change?
In case you just need to update chart's size or position, there is Advanced Sheets Service and specific UpdateEmbeddedObjectPosition request for that (which is not available via built-in Spreadsheet Service).
Example:
// your spreadsheet id
let ssId = SpreadsheetApp.getActiveSpreadsheet().getId();
// get all charts from sheet (replace 'YOUR_SHEET_TITLE')
let charts = Sheets.Spreadsheets
.get(ssId)
.sheets
.filter(sheet => sheet.properties.title === 'YOUR_SHEET_TITLE')[0]
.charts;
// array of requests
let requests = [];
// loop charts array
charts.forEach(chart => {
// create request for each chart
let request = {
updateEmbeddedObjectPosition: {
objectId: chart.chartId,
newPosition: {
overlayPosition: {
widthPixels: 1000 // specify your width
}
},
fields: 'widthPixels' // list fields you want to update
}
};
// push request to array
requests.push(request);
});
// send requests at once
// this may allow to improve script performance
Sheets.Spreadsheets.batchUpdate({'requests': requests}, ssId);
References:
Updating Spreadsheets - Apps Script
Updating Spreadsheets - Field masks
UpdateEmbeddedObjectPositionRequest
EmbeddedObjectPosition
OverlayPosition
Good day. Please tell me how I can convert this script to use Google sheets api v4
and reduce the cost of the request. Understand correctly that I need to dig to the side:
https://developers.google.com/sheets/api/samples/conditional-formatting?hl=en#add_a_conditional_formatting_rule_to_a_set_of_ranges
?
Sample code below
while (folders.hasNext()) {
var folder = folders.next().getId();
var sheet1 = SpreadsheetApp.openById(folder);
var sheet = sheet1.getActiveSheet();
var r1 = sheet.getRange('Q4:Q');var r2 = sheet.getRange('S4:S');
var rule = SpreadsheetApp.newConditionalFormatRule()
.setGradientMaxpoint("#06ff00")
.setGradientMidpointWithValue("#ffef00", SpreadsheetApp.InterpolationType.PERCENTILE, "50")
.setGradientMinpoint("#ff0000")
.setRanges([r1,r2,r3,r4,r5,r6,r7,r8,r9,r10,
r11,r12,r13,r14,r15,r16,r17,r18,r19,r20,
r21,r22,r23,r24,r25,r26,r27,r28,r29,r30,
r31,r32,r33,r34,r35,r36,r37,r38,r39,r40,
r41,r42,r43,r44,r45,r46,r47,r48,r49,r50,
r51,r52,r53,r54,r55,r56,r57,r58,r59,r60,
r61,r62,r63,r64,r65])
.build()
var rules = sheet.getConditionalFormatRules();
rules.push(rule);
sheet.setConditionalFormatRules(rules);
}
I will be grateful for any help
Answer
I understand that you want to use Sheet API v4 instead of Spreadsheet Service to reduce the cost of the request. I don't know how much the cost will be reduced using that way, but I will explain to you how to do it.
How to apply a Conditional Format Rule in Sheets API v4
Use the method batchUpdate. It takes a request body where you can define the Conditional Format Rule and the spreadsheetId. You can easily construct the request body using the section Try this API, it helps you to put and define all the parameters that you need.
Define the request body with a AddConditionalFormatRuleRequest object. It has two fields, the rule that describes the conditional format and the index that defines where the rule should be inserted.
Define the rule field with a ConditionalFormatRule object. It takes two fields, the ranges and the gradientRule or the boolearnRule (you can only choose one).
Define the range with a GridRange object.
Define the gradientRule with its three fields: minpoint, midpoint and maxpoint. Each of these is defined by an InterpolationPoint object.
Finally your code will look similar to the following:
function main(){
// start here
var folders = // your definition
const gridRangeList = createGridRange() // create the GridRange object
while (folders.hasNext()) {
var spreadsheetId = folders.next().getId();
applyConditionalFormating(spreadsheetId, gridRangeList) // apply the conditional format
}
}
function createGridRange(){
const ranges = ["Q4:Q", "S4:S"]
const temp = SpreadsheetApp.create("temp")
const rangeList = temp.getSheets()[0].getRangeList(ranges).getRanges()
const gridRangeList = rangeList.map(r => ({startRowIndex: r.getRow() - 1, startColumnIndex: r.getColumn() - 1, endColumnIndex: r.getColumn() + r.getNumColumns() - 1}))
DriveApp.getFileById(temp.getId()).setTrashed(true) // move the file to the trash
return gridRangeList
}
function applyConditionalFormating(spreadsheetId, gridRangeList){
const request = {
"requests": [
{
"addConditionalFormatRule": {
"rule": {
"gradientRule": {
"maxpoint": {
"type": "MAX",
"color": {red:6/255,green:255/255,blue:0}
},
"midpoint": {
"type": "PERCENTILE",
"value": "50",
"color": {red:255/255,green:239/255,blue:0}
},
"minpoint": {
"type": "MIN",
"color":{red:255/255,green:0,blue:0}
}
},
"ranges": [gridRangeList]
},
"index": 0
}
}
]
}
Sheets.Spreadsheets.batchUpdate(request,spreadsheetId)
}
Reference
Sheet API v4
Spreadsheet Service
Conditional Format Rule
Method: spreadsheets.batchUpdate
AddConditionalFormatRuleRequest
ConditionalFormatRule
GridRange
gradientRule
InterpolationPoint
I believe your goal as follows.
You want to reduce the process cost of your script.
Modification points:
When I saw your script, it seems that a conditional format rule with multiple ranges is added to a sheet in a Google Spreadsheet by one call. In this case, even when this script is converted to Sheets API instead of Spreadsheet service, the process cost might not be the large change. So please test the following modified script.
As the modification point, I would like to propose as follows.
In your script, the ranges are declared in the loop. When this is converted to Sheets API, the ranges can be created at outside of the loop.
When the file list is retrieved using Drive API, the process cost will be reduced a little.
When above points are reflected to your script, it becomes as follows.
Modified script:
Before you use this script, please enable Sheets API and Drive API at Advanced Google services. And, please set the variables of topFolderId and ranges. ranges is from your script. When you want to more ranges, please add them to the array.
function myFunction() {
var topFolderId = "###"; // Please set the top folder ID of the folder including the Spreadsheet.
// Retrieve file list using Drive API v3.
const headers = {authorization: `Bearer ${ScriptApp.getOAuthToken()}`};
const q = `'${topFolderId}' in parents and mimeType='${MimeType.GOOGLE_SHEETS}' and trashed=false`;
const url = `https://www.googleapis.com/drive/v3/files?pageSize=1000&q=${q}&fields=${encodeURIComponent("nextPageToken,files(id)")}`;
let pageToken = "";
let files = [];
do {
const res = UrlFetchApp.fetch(url + "&pageToken=" + pageToken, {headers: headers, muteHttpExceptions: true});
if (res.getResponseCode() != 200) throw new Error(res.getContentText());
const obj = JSON.parse(res.getContentText());
files = files.concat(obj.files);
pageToken = obj.nextPageToken || "";
} while(pageToken);
// Create range list.
const ranges = ["Q4:Q", "S4:S"]; // Please set the ranges as A1Notation. These ranges are used for addConditionalFormatRule.
const temp = SpreadsheetApp.create("temp");
const rangeList = temp.getSheets()[0].getRangeList(ranges).getRanges();
const gridRangeList = rangeList.map(r => ({startRowIndex: r.getRow() - 1, startColumnIndex: r.getColumn() - 1, endColumnIndex: r.getColumn() + r.getNumColumns() - 1}));
DriveApp.getFileById(temp.getId()).setTrashed(true);
// Request Sheets API for a sheet in each Spreadsheet.
files.forEach(({id}) => {
const sheet1 = SpreadsheetApp.openById(id);
const gr = gridRangeList.map(({startRowIndex, startColumnIndex, endColumnIndex}) => ({sheetId: sheet1.getSheetId(), startRowIndex, startColumnIndex, endColumnIndex}))
const requests = {addConditionalFormatRule:{rule:{gradientRule:{maxpoint:{color:{red:6/255,green:255/255,blue:0},type:"MAX"},midpoint:{color:{red:255/255,green:239/255,blue:0},type:"PERCENTILE",value:"50"},minpoint:{color:{red:255/255,green:0,blue:0},type:"MIN"}},ranges:[gr]},index:0}};
Sheets.Spreadsheets.batchUpdate({requests: requests}, id);
});
}
References:
Method: spreadsheets.batchUpdate
AddConditionalFormatRuleRequest
This question already has answers here:
Scraping data to Google Sheets from a website that uses JavaScript
(2 answers)
Closed last month.
I'm attempting to scrape options pricing data from Yahoo Finance in Google Sheets. Although I'm able to pull the options chain just fine, i.e.
=IMPORTHTML("https://finance.yahoo.com/quote/TCOM/options?date=1610668800","table",2)
I find that it's returning results that don't completely match what's actually shown on Yahoo Finance. Specifically, the scraped results are incomplete - they're missing some strikes. i.e. the first 5 rows of the chart may match, but then it will start returning only every other strike (aka skipping every other strike).
Why would IMPORTHTML be returning "abbreviated" results, which don't match what's actually shown on the page? And more importantly, is there some way to scrape complete data (i.e. that doesn't skip some portion of the available strikes)?
In Yahoo finance, all data are available in a big json called root.App.main. So to get the complete set of data, proceed as following
var source = UrlFetchApp.fetch(url).getContentText()
var jsonString = source.match(/(?<=root.App.main = ).*(?=}}}})/g) + '}}}}'
var data = JSON.parse(jsonString)
You can then choose to fetch the informations you need. Take a copy of this example https://docs.google.com/spreadsheets/d/1sTA71PhpxI_QdGKXVAtb0Rc3cmvPLgzvXKXXTmiec7k/copy
edit
if you want to get a full list of available data, you can retrieve it by this simple script
// mike.steelson
let result = [];
function getAllDataJSON(url = 'https://finance.yahoo.com/quote/TCOM/options?date=1610668800') {
var source = UrlFetchApp.fetch(url).getContentText()
var jsonString = source.match(/(?<=root.App.main = ).*(?=}}}})/g) + '}}}}'
var data = JSON.parse(jsonString)
getAllData(eval(data),'data')
var sh = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet()
sh.getRange(1, 1, result.length, result[0].length).setValues(result);
}
function getAllData(obj,id) {
const regex = new RegExp('[^0-9]+');
for (let p in obj) {
var newid = (regex.test(p)) ? id + '["' + p + '"]' : id + '[' + p + ']';
if (obj[p]!=null){
if (typeof obj[p] != 'object' && typeof obj[p] != 'function'){
result.push([newid, obj[p]]);
}
if (typeof obj[p] == 'object') {
getAllData(obj[p], newid );
}
}
}
}
Here's a simpler way to get the last market price of a given option. Add this function to you Google Sheets Script Editor.
function OPTION(ticker) {
var ticker = ticker+"";
var URL = "finance.yahoo.com/quote/"+ticker;
var html = UrlFetchApp.fetch(URL).getContentText();
var count = (html.match(/regularMarketPrice/g) || []).length;
var query = "regularMarketPrice";
var loc = 0;
var n = parseInt(count)-2;
for(i = 0; i<n; i++) {
loc = html.indexOf(query,loc+1);
}
var value = html.substring(loc+query.length+9, html.indexOf(",", loc+query.length+9));
return value*100;
}
In your google sheets input the Yahoo Finance option ticker like below
=OPTION("AAPL210430C00060000")
I believe your goal as follows.
You want to retrieve the complete table from the URL of https://finance.yahoo.com/quote/TCOM/options?date=1610668800, and want to put it to the Spreadsheet.
Issue and workaround:
I could replicate your issue. When I saw the HTML data, unfortunately, I couldn't find the difference of HTML between the showing rows and the not showing rows. And also, I could confirm that the complete table is included in the HTML data. By the way, when I tested it using =IMPORTXML(A1,"//section[2]//tr"), the same result of IMPORTHTML occurs. So I thought that in this case, IMPORTHTML and IMPORTXML might not be able to retrieve the complete table.
So, in this answer, as a workaround, I would like to propose to put the complete table parsed using Sheets API. In this case, Google Apps Script is used. By this, I could confirm that the complete table can be retrieved by parsing the HTML table with Sheet API.
Sample script:
Please copy and paste the following script to the script editor of Spreadsheet, and please enable Sheets API at Advanced Google services. And, please run the function of myFunction at the script editor. By this, the retrieved table is put to the sheet of sheetName.
function myFunction() {
// Please set the following variables.
const url ="https://finance.yahoo.com/quote/TCOM/options?date=1610668800";
const sheetName = "Sheet1"; // Please set the destination sheet name.
const sessionNumber = 2; // Please set the number of session. In this case, the table of 2nd session is retrieved.
const html = UrlFetchApp.fetch(url).getContentText();
const section = [...html.matchAll(/<section[\s\S\w]+?<\/section>/g)];
if (section.length >= sessionNumber) {
if (section[sessionNumber].length == 1) {
const table = section[sessionNumber][0].match(/<table[\s\S\w]+?<\/table>/);
if (table) {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const body = {requests: [{pasteData: {html: true, data: table[0], coordinate: {sheetId: ss.getSheetByName(sheetName).getSheetId()}}}]};
Sheets.Spreadsheets.batchUpdate(body, ss.getId());
}
} else {
throw new Error("No table.");
}
} else {
throw new Error("No table.");
}
}
const sessionNumber = 2; means that 2 of =IMPORTHTML("https://finance.yahoo.com/quote/TCOM/options?date=1610668800","table",2).
References:
Method: spreadsheets.batchUpdate
PasteDataRequest
I'm looking for some help. I am trying to grab an author's publications from PubMed and populate the data into Google Sheets using Apps Script. I've gotten as far as the code below and am now stuck.
Basically, what I have done was first pull all the Pubmed IDs from a particular author whose name comes from the name of the sheet. Then I have tried creating a loop to go through each Pubmed ID JSON summary and pull each field I want. I have been able to pull the pub date. I had set it up with the idea that I would do a loop for each field of that PMID I want, store it in an array, and then return it to my sheet. However, I'm now stuck trying to get the second field - title - and all the subsequent fields (e.g. authors, last author, first author, etc.)
Any help would be greatly appreciated.
function IMPORTPMID(){
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sheet = ss.getSheets()[0];
var author = sheet.getSheetName();
var url = ("https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi?db=pubmed&term=" + author + "[author]&retmode=json&retmax=1000");
var response = UrlFetchApp.fetch(url);
var AllAuthorPMID = JSON.parse(response.getContentText());
var xpath = "esearchresult/idlist";
var patharray = xpath.split("/");
for (var i = 0; i < patharray.length; i++) {
AllAuthorPMID = AllAuthorPMID[patharray[i]];
}
var PMID = AllAuthorPMID;
var PDparsearray = [PMID.length];
var titleparsearray = [PMID.length];
for (var x = 0; x < PMID.length; x++) {
var urlsum = ("https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi?db=pubmed&retmode=json&rettype=abstract&id=" + PMID[x]);
var ressum = UrlFetchApp.fetch(urlsum);
var contentsum = ressum.getContentText();
var jsonsum = JSON.parse(contentsum);
var PDpath = "result/" + PMID[x] + "/pubdate";
var titlepath = "result/" + PMID[x] + "/title";
var PDpatharray = PDpath.split("/");
var titlepatharray = titlepath.split("/");
for (var j = 0; j < PDpatharray.length; j++) {
var jsonsum = jsonsum[PDpatharray[j]];
}
PDparsearray[x] = jsonsum;
}
var tempArr = [];
for (var obj in AllAuthorPMID) {
tempArr.push([obj, AllAuthorPMID[obj], PDparsearray[obj]]);
}
return tempArr;
}
From a PubMed JSON response for a given PubMed ID, you should be able to determine the fieldnames (and paths to them) that you want to include in your summary report. Reading them all is simpler to implement if they are all at the same level, but if some are properties of a sub-field, you can still access them if you give the right path in your setup.
Consider the "source JSON":
[
{ "pubMedId": "1234",
"name": "Jay Sahn",
"publications": [
{ "pubId": "abcd",
"issn": "A1B2C3",
"title": "Dynamic JSON Parsing: A Journey into Madness",
"authors": [
{ "pubMedId": "1234" },
{ "pubMedId": "2345" }
]
},
{ "pubId": "efgh",
...
},
...
],
...
},
...
]
The pubId and issn fields would be at the same level, while the publications and authors would not.
You can retrieve both the pubMedId and publications fields (and others you desire) in the same loop by either 1) hard-coding the field access, or 2) writing code that parses a field path and supplying field paths.
Option 1 is likely to be faster, but much less flexible if you suddenly want to get a new field, since you have to remember how to write the code to access that field, along with where to insert it, etc. God save you if the API changes.
Option 2 is harder to get right, but once right, will (should) work for any field you (properly) specify. Getting a new field is as easy as writing the path to it in the relevant config variable. There are possibly libraries that will do this for you.
To convert the above into spreadsheet rows (one per pubMedId in the outer array, e.g. the IDs you queried their API for), consider this example code:
function foo() {
const sheet = /* get a sheet reference somehow */;
const resp = UrlFetchApp.fetch(...).getContentText();
const data = JSON.parse(resp);
// paths relative to the outermost field, which for the imaginary source is an array of "author" objects
const fields = ['pubMedId', 'name', 'publications/pubId', 'publications/title', 'publications/authors/pubMedId'];
const output = data.map(function (author) {
var row = fields.map(function (f) {
var desiredField = f.split('/').reduce(delve_, author);
return JSON.stringify(desiredField);
});
return row;
});
sheet.getRange(1, 1, output.length, output[0].length).setValues(output);
}
function delve_(parentObj, property, i, fullPath) {
// Dive into the given object to get the path. If the parent is an array, access its elements.
if (parentObj === undefined)
return;
// Simple case: parentObj is an Object, and property exists.
const child = parentObj[property];
if (child)
return child;
// Not a direct property / index, so perhaps a property on an object in an Array.
if (parentObj.constructor === Array)
return collate_(parentObj, fullPath.splice(i));
console.warn({message: "Unhandled case / missing property",
args: {parent: parentObj, prop: property, index: i, pathArray: fullPath}});
return; // property didn't exist, user error.
}
function collate_(arr, fields) {
// Obtain the given property from all elements of the array.
const results = arr.map(function (element) {
return fields.slice().reduce(delve_, element);
});
return results;
}
Executing this yields the following output in Stackdriver:
Obviously you probably want some different (aka real) fields, and probably have other ideas for how to report them, so I leave that portion up to the reader.
Anyone with improvements to the above is welcome to submit a PR.
Recommended Reading:
Array#reduce
Array#map
Array#splice
Array#slice
Internet references on parsing nested JSON. There are a lot.
I'm working on a script that interacts with Google Form' response sheet.
FormApp.getActiveForm().getDestinationId()
give me the spreadsheet id, but I don't find a way to get the sheet itself. User can change its name and position, so I need to get its id, like in
Sheet.getSheetId()
I also have to determine the number of columns the responses uses. It's not equal to the number of questions in the form. I can count the number of items in the form:
Form.getItems().length
and then search for gridItems, add the number of rows in each and add them minus one:
+ gridItem.getRows().length - 1
Finally, I think there's no way to relate each question with each column in the sheet, but by comparing somehow columns names with items title.
Thank you
#tehhowch came very close to the correct answer, but there is a problem with the code: there is no guarantee that form.getPublishedUrl() and sheet.getFormUrl() will return exactly the same string. In my case, form.getPublishedUrl() returned a URL formed as https://docs.google.com/forms/d/e/{id}/viewform and sheet.getFormUrl() returned https://docs.google.com/forms/d/{id}/viewform. Since the form id is part of the URL, a more robust implementation would be:
function get_form_destination_sheet(form) {
const form_id = form.getId();
const destination_id = form.getDestinationId();
if (destination_id) {
const spreadsheet = SpreadsheetApp.openById(destination_id);
const matches = spreadsheet.getSheets().filter(function (sheet) {
const url = sheet.getFormUrl();
return url && url.indexOf(form_id) > -1;
});
return matches.length > 0 ? matches[0] : null;
}
return null;
}
There is now a way to verify which sheet in a Google Sheets file with multiple linked forms corresponds to the current Form - through the use of Sheet#getFormUrl(), which was added to the Sheet class in 2017.
function getFormResponseSheet_(wkbkId, formUrl) {
const matches = SpreadsheetApp.openById(wkbkId).getSheets().filter(
function (sheet) {
return sheet.getFormUrl() === formUrl;
});
return matches[0]; // a `Sheet` or `undefined`
}
function foo() {
const form = FormApp.getActiveForm();
const destSheet = getFormResponseSheet_(form.getDestinationId(), form.getPublishedUrl());
if (!destSheet)
throw new Error("No sheets in destination with form url '" + form.getPublishedUrl() + "'");
// do stuff with the linked response destination sheet.
}
If you have unlinked the Form and the destination spreadsheet, then obviously you won't be able to use getDestinationId or getFormUrl.
I needed this also, and remarkably there is still no apps script method that facilitates it. In the end I set about finding a reliable way to determine the sheet id, and this is what I ended up with by way of programmatic workaround:
Add a temporary form item with a title that's a random string (or something similarly suitable)
Wait for the new corresponding column to be added to the destination sheet (typically takes a few seconds)
Look though each sheet in the destination until you find this new form item title string in a header row
Delete the temporary form item that was added
Wait for the corresponding column in the sheet to unlink from the form and become deletable (typically takes a few seconds)
Delete the column corresponding to the temporary form item
Return the sheet ID
I'm sure some won't like this approach because it modifies the form and spreadsheet, but it does work well.
With the necessary wait times included it takes about 12 seconds to perform all the look up / clean up operations.
Here's my code for this method in case anyone else might like to use it.
// Takes Apps Script 'Form' object as single paramater
// The second parameter 'obj', is for recursion (do not pass a second parameter)
// Return value is either:
// - null (if the form is not linked to any spreadsheet)
// - sheetId [int]
// An error is thrown if the operations are taking too long
function getFormDestinationSheetId(form, obj) {
var obj = obj || {}; // Initialise object to be passed between recursions of this function
obj.attempts = (obj.attempts || 1);
Logger.log('Attempt #' + obj.attempts);
if (obj.attempts > 14) {
throw 'Unable to determine destination sheet id, too many failed attempts, taking too long. Sorry!';
}
obj.spreadsheetId = obj.spreadsheetId || form.getDestinationId();
if (!obj.spreadsheetId) {
return null; // This means there actually is no spreadsheet destination set at all.
} else {
var tempFormItemTitle = '### IF YOU SEE THIS, PLEASE IGNORE! ###';
if (!obj.tempFormItemId && !obj.sheetId) { // If the sheet id exists from a previous recusion, we're just in a clean up phase
// Check that temp item does not already exist in form
form.getItems(FormApp.ItemType.TEXT).map(function(textItem) {
var textItemTitle = textItem.getTitle();
Logger.log('Checking against form text item: ' + textItemTitle);
if (textItemTitle === tempFormItemTitle) {
obj.tempFormItemId = textItem.getId();
Logger.log('Found matching form text item reusing item id: ' + obj.tempFormItemId);
}
return 0;
}); // Note: Just using map as handy iterator, don't need to assign the output to anything
if (!obj.tempFormItemId) {
Logger.log('Adding temporary item to form');
obj.tempFormItemId = form.addTextItem().setTitle(tempFormItemTitle).getId();
}
}
obj.spreadsheet = obj.spreadsheet || SpreadsheetApp.openById(obj.spreadsheetId);
obj.sheets = obj.sheets || obj.spreadsheet.getSheets();
obj.sheetId = obj.sheetId || null;
var sheetHeaderRow = null;
for (var i = 0, x = obj.sheets.length; i < x; i++) {
sheetHeaderRow = obj.sheets[i].getSheetValues(1, 1, 1, -1)[0];
for (var j = 0, y = sheetHeaderRow.length; j < y; j++) {
if (sheetHeaderRow[j] === tempFormItemTitle) {
obj.sheetId = obj.sheets[i].getSheetId();
Logger.log('Temporary item title found in header row of sheet id: ' + obj.sheetId);
break;
}
}
if (obj.sheetId) break;
}
// Time to start cleaning things up a bit!
if (obj.sheetId) {
if (obj.tempFormItemId) {
try {
form.deleteItem(form.getItemById(obj.tempFormItemId));
obj.tempFormItemId = null;
Logger.log('Successfully deleted temporary form item');
} catch (e) {
Logger.log('Tried to delete temporary form item, but it seems it was already deleted');
}
}
if (obj.sheetId && !obj.tempFormItemId && !obj.tempColumnDeleted) {
try {
obj.sheets[i].deleteColumn(j + 1);
obj.tempColumnDeleted = true;
Logger.log('Successfully deleted temporary column');
} catch (e) {
Logger.log('Could not delete temporary column as it was still attached to the form');
}
}
if (!obj.tempFormItemId && obj.tempColumnDeleted) {
Logger.log('Completed!');
return obj.sheetId;
}
}
SpreadsheetApp.flush(); // Just in case this helps!
// Normally this process takes three passes, and a delay of 4.5 secs seems to make it work in only 3 passes most of the time
// Perhaps if many people are submitting forms/editing the spreadsheet, this delay would not be long enough, I don't know.
obj.delay = ((obj.delay || 4500));
// If this point is reached then we're not quite finished, so try again after a little delay
Logger.log('Delay before trying again: ' + obj.delay / 1000 + ' secs');
Utilities.sleep(obj.delay);
obj.attempts++;
return getFormDestinationSheetId(form, obj);
}
}
To get the spreadsheet, once you have the DestinationID, use SpreadsheetApp.openById(). Once you have that, you can retrieve an array of sheets, and get the response sheet by index, regardless of its name.
var destId = FormApp.getActiveForm().getDestinationId();
var ss = SpreadsheetApp.openById(destId);
var respSheet = ss.getSheets()[0]; // Forms typically go into sheet 0.
...
From this point, you can manipulate the data in the spreadsheet using other Spreadsheet Service methods.
I also have to determine the number of columns the responses uses. It's not equal to the number of questions in the form. I can count the number of items in the form... (but that doesn't match the spreadsheet)
You're right - the number of current items does not equal the number of columns in the spreadsheet. The number of columns each response takes up in the destination sheet includes any questions that have been deleted from the form, and excludes items that are not questions. Also, the order of the columns in the spreadsheet is the order that questions were created in - as you re-arrange your form or insert new questions, the spreadsheet column order does not reflect the new order.
Assuming that the only columns in the spreadsheet are from forms, here's how you could make use of them:
...
var data = respSheet.getDataRange().getValues(); // 2d array of form responses
var headers = data[0]; // timestamp and all questions
var numColumns = headers.length; // count headers
var numResponses = data.length - 1; // count responses
And your last point is correct, you need to correlate names.
Finally, I think there's no way to relate each question with each column in the sheet, but by comparing somehow columns names with items title.