Google Script sort 2D Array by any column - google-apps-script

I had a asked an earlier question about retrieving records from a database, here: Retrieving Records from a Google Sheet with Google Script
I'm fairly comfortable with manipulating arrays and creating my own sorting algorithms, but I want to use the existing Array.sort() method to organize the data because of its speed. I'm finding that I can easily use this to sort a 2D array by the first column of data, but I can't find the syntax to sort on a different column of data, other than the first.
The closest that I've found is this: Google Apps Script Additional Sorting Rules. However, these inputs haven't worked for me. Here is what I get for the following code, for my array, tableData:
tableData.sort([{ column: 1}]);
=>TypeError: (class)#4dde8e64 is not a function, it is object. (line 49, file "sortTablebyCol")
tableData.sort([{column: 1, ascending: true}]);
=> TypeError: (class)#4d89c26e is not a function, it is object. (line 50, file "sortTablebyCol")
What is the proper syntax for choosing which column of data to sort on?

The array.sort method can have a function argument to choose on what part you want to sort. Code goes like this :
array.sort(function(x,y){
var xp = x[3];
var yp = y[3];
// in this example I used the 4th column...
return xp == yp ? 0 : xp < yp ? -1 : 1;
});
EDIT
Following your comment, here is a small demo function that should help to understand how this works.
Instead of using short form if/else condition I used the traditional form and splitted it in 3 lines to make it easier to understand.
function demo(){
// using a full sheet as array source
var array = SpreadsheetApp.getActive().getActiveSheet().getDataRange().getValues();
Logger.log('Unsorted array = '+array);
array.sort(function(x,y){
// in this example I used the 4th column...
var compareArgumentA = x[3];
var compareArgumentB = y[3];
// eventually do something with these 2 variables, for example Number(x[0]) and Number(y[0]) would do the comparison on numeric values of first column in the array (index0)
// another example x[0].toLowerCase() and y[0].toLowerCase() would do the comparison without taking care of letterCase...
Logger.log('compareArgumentA = '+compareArgumentA+' and compareArgumentB = '+compareArgumentB);
var result = 0;// initialize return value and then do the comparison : 3 cases
if(compareArgumentA == compareArgumentB ){return result }; // if equal return 0
if(compareArgumentA < compareArgumentB ){result = -1 ; return result }; // if A<B return -1 (you can change this of course and invert the sort order)
if(compareArgumentA > compareArgumentB ){result = 1 ; return result }; // if a>B return 1
}
);
Logger.log('\n\n\nSorted array = '+array);
}
I added a couple of Logger.log to check starting, intermediate and final values. Try this in a spreadsheet.
Hoping this will help.

It seems if instead of using .getValues , you restrict to .getDataRange then perhaps your original sort code "tableData.sort([{column: 1, ascending: true}]);" can work if you avoid the square bracket.
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sheet = ss.getSheets()[0];
var range = sheet.getRange("A1:C7");
// Sorts by the values in the first column (A)
range.sort(1);
// Sorts by the values in the second column (B)
range.sort(2);
// Sorts descending by column B
range.sort({column: 2, ascending: false});
I found this in Google Documentation

My suggestion is to use a library like underscore.js which has a lot of useful functions to manipulate collections, arrays, map/reduce, sorting etc... Works without a glitch with Google Apps Script. That's the first library I add to any project I start on GAP.

Related

How do I get values of a column in an array and search for a value in it? (Google Sheets' Apps Script)

I have this sheet in my Google sheets spreadsheet which looks like this - (this is a simpler representation of my actual data)
Now let's say I want to find the row number of where animal = dog - wrt above data I should get 4 in return - as in the column 1, the value "dog" is in the 4th row. I built this code in Apps Script but it keeps returning -1 and I don't understand why...
var values = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet().getDataRange().getValues();
Logger.log(values);
//above prints [[animal, owner], [cat, Max], [doggo, Sam], [dog, Jack], [cow, Mary], [rabbit, Jimmy]]
var index = values[0].indexOf("dog");
var responsePrint = index;
Logger.log(responsePrint); // -1 is printed
What should I do if I want to get the 1st column of my sheet in an array and search for a particular value in it? I'm trying to avoid using a loop to scan each and every element, as my data might get large - so if an inbuilt function can do this, it'd be great for me... Any idea is appreciated! Thanks :)
P.S. I'd like to say this might sound like a question which has been asked before, but trust me I've searched a lot, and I can't seem to find a suitable solution for me...
values is a 2D array, or an array of arrays. The first index of values is the row and the second index is the column so values[1][0] would be the second row first column.
So in your case you could do this.
let index = values.findIndex( row => row[0] === dog );
To get the row you have to add 1 to the index because index are zero based.
What findIndex does is it takes each element of values which is a "row" and then looks at the first value in the row array and compares to "dog"
Reference
Array.findIndex()
Arrow function
If you want to use indexOf you need to isolate the first column of your data and then use the indexOf method:
function myFunction(){
var values = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet().getDataRange().getValues();
var index = values.map(r=>r[0]).indexOf("dog") + 1;
console.log(index); //outputs 4
}

How To Add The Ordinal Count To Values Set In Horizontal Non-Contiguous Ranges In Google Sheets With A Formula Or A Script? EDIT ADDRESS FUNCTION

Problem:
I'm trying to add each ordinal reference to a set of repeating values in each cells just above each value.
The values are organized in horizontal and non-contiguous order.
The illustration example I show below is simple for testing purposes, but the end use should be for hundreds of values/ranges, so it would be optimal to use a script or a simplified version of the formula I found.
Illustration Example:
Other Related Question and Solution:
I found that question and answers that address the same question but for vertical and contiguous values using the following formula as solution:
=COUNTIF(A$1:A1,A1)
=COUNTIF(A$1:A1,A1)&MID("thstndrdth",MIN(9,2*RIGHT(COUNTIF(A$1:A1,A1))*(MOD(COUNTIF(A$1:A1,A1)-11,100)>2)+1),2)
Calculate ordinal number of replicates
My Formula So Far:
=TRANSPOSE(INDIRECT($P$21&(SUM(Q21))&":"&$P$21&(SUM(Q21,I22)-1)))
=TRANSPOSE(INDIRECT($P$21&(SUM(Q21,I22))&":"&$P$21&(SUM(Q21,I22,I26)-1)))
=TRANSPOSE(INDIRECT($P$21&(SUM(Q21,I22,I26))&":"&$P$21&(SUM(Q21,I22,I26,I30)-1)))
I use the above formula and need to copy-paste it in the cell immediately above the 1st cell of each horizontal range.
I need to reference each cell in the SUM Functions part because the spreadsheet will act as a template, with new data sets that will be different each time.
Therefore the cells need to return output in some dynamic way (can't hardcode them).
The formula problem is it requires an ever growing number of cells reference as we get to new ranges. It becomes difficult for hundreds of horizontal ranges, because of the growing inline cells to add to the SUM Functions.
It is also prone to errors. And possibly it can break if rows or columns are added afterwards.
Trials:
I originally didn't think of using the INDIRECT Function (I never needed before). But I don't know any other Google Sheets function able to achieve the end results in a simpler way.
Questions:
What way to avoid the SUM Function method for the same result would you suggest, for a formula solution?
For a formula, what simpler function-s than the INDIRECT and/or SUM would be more efficient?
I also thought of using a script for doing that, but I can't put the whole idea into a manageable script concept process. What would you suggest if a script would be more appropriate?
Many thanks for your help!
The Sample Sheet:
Sample Sheet
EDIT:
I just found about the ADDRESS Function from this answer by Player0 (to help greatly simplify the INDIRECT function row and column references):
Google Sheets: INDIRECT() with a Range
References:
Excel INDIRECT Function
Google Sheets ADDRESS Function
I was able to create a script to show the ordinal number of the replicates but is only respective to one range. EDIT: I have modified it to also accept multiple row ranges. See updated answer below:
Script:
function showOrdinal(range) {
range = "A4:E12";
var values = SpreadsheetApp.getActiveSheet().getRange(range).getValues();
var output = [];
var order, subTotal;
values.forEach((x, i) => {
if(x.flat().filter(String).length) {
subTotal = values.slice(0, i + 1).flat().filter(String);
order = x.filter(String);
if (order[0] != '-') {
var row = order.map(x => {
return getNumberWithOrdinal(subTotal.filter(e => e == x).length);
})
row = [...row, ...Array(x.length - row.length)];
output.push(row);
if(output.length > 2)
output.splice(output.length - 2, 1);
}
order = [...order, ...Array(x.length - order.length)];
output.push(order)
}
else
output.push(x);
});
// console.log(output);
SpreadsheetApp.getActiveSheet().getRange(3, 21, output.length, output[0].length).setValues(output);
// return output;
}
// get ordinal number
// https://stackoverflow.com/a/31615643/14606045
function getNumberWithOrdinal(n) {
var s = ["th", "st", "nd", "rd"],
v = n % 100;
return n + (s[(v - 20) % 10] || s[v] || s[0]);
}
Output:
Note:
The range that is to be passed assumes that the first row should be the first range, not a blank one. And also the last row of the range should contain the last row of the data.
Any unrelated data should be starting with - on the first column so it can be allowed without processing it.

Stop custom function from auto refreshing/periodically calling external API

I am using Google Apps Script and a custom function to call an external API to verify phone numbers.
Below is the code for my function.
/**
* This CUSTOM FUNCTION uses the numVerify API to validate
* a phone number based on the input from JotForm and a
* country code which is derived from the JotForm country
*
* Numverify website: https://numverify.com/dashboard (account via LastPass)
* Numverify docs: https://numverify.com/documentation
*/
function PHONE_CHECK(number, country){
if(country == "")
return [["", "country_not_set"]]
// check the API result has already been retrieved
var range = SpreadsheetApp.getActiveSheet().getActiveRange()
var apires = range.offset(0, 1).getValue()
if(apires.length > 0)
return range.offset(0, 0, 1, 2).getValues()
var url = 'http://apilayer.net/api/validate'
+ '?access_key=' + NUMVERIFY_KEY
+ '&number=' + encodeURIComponent(number)
+ '&country_code=' + encodeURIComponent(country)
+ '&format=1';
var response = UrlFetchApp.fetch(url, {'muteHttpExceptions': true});
var json = response.getContentText();
var data = JSON.parse(json);
if(data.valid !== undefined){
if(data.valid){
return [[data.international_format, "OK"]]
}else{
return [["", "invalid_number"]] // overflows data to the next column (API Error) while keeping the phone field clear for import into TL
}
}else if(data.success !== undefined){
if(data.error.type.length > 0){
return [[number, data.error.type]]
}else{
return [[number, "no_error_type"]]
}
}else{
return [[number, "unexpected_error"]] // this generally shouldn't happen...
}
}
Given this formula, which takes a phone number and country code, it will then check the phone number against the numverify API and return the result in the cell and overflow to the cell to the right of it. The overflow is used to indicate whether the API was called successfully and to check if the result was already retrieved.
Example:
=PHONE_CHECK("+32123456789", "BE")
Note that the first cell is empty because the API returns an 'invalid phone number' code. Because of privacy, I won't put any real phone numbers here. In case I would've used a real phone number, the first cell would contain the phone number formatted in the international number format.
Since I'm using the free plan, I don't want to rerun the function every time if I already know what the result is, as I don't want to run up against the rate limit. Unfortunately, this doesn't seem to work and periodically (it looks like once every day), it will refresh the results for each row in the sheet.
So two questions:
Is something wrong with my logic in checking the API result and then just exiting the function? (see below for the code)
If the logic is right, why does Google Sheets seem to periodically ignore (or refresh?) the values in that second column and call the external API anyhow?
var range = SpreadsheetApp.getActiveSheet().getActiveRange() // get the cell from which the function is called
var apires = range.offset(0, 1).getValue() // get the values directly to the right of the cell
if(apires.length > 0) // check if there's anything there...
return range.offset(0, 0, 1, 2).getValues() // return an array that basically just resets the same values, effectively stopping the script from running
Your Aim:
You want a custom function, AKA a formula to only run once, or as many times as is necessary to produce a certain result.
You want the same formula to write a value to the another cell, for example the adjacent cell, that will tell the formula in future, if it should be run again or not.
Short Answer:
I'm afraid that values that are evaluated from custom functions AKA formulas are transient, and what you want to accomplish is not possible with them.
Explanation:
You can run a quick test with this custom function:
function arrayTest() {
return [[1, 2, 3, 4 ,5]]
}
If you put this in a cell as below:
You will see that if you delete the formula in the original cell, the overflow values also dissapear.
Therefore something like the following code will almost always produce the same value:
function checkTest() {
var cell = SpreadsheetApp.getActiveRange()
var status = cell.offset(0, 1).getValue();
if (status != "") {
return "already executed" // in your case without calling API
} else {
return [["OK","executed"]] // in your case making API call - will happen ~90% of the time.
}
}
// OUTPUT [["OK","executed"]]
Here I am inserting a row and deleting it to force re-calculation of the formulas.
The first thing that Sheets does before re-calculating a formula is that it clears the previous values populated by formula. Since the conditional statment depends on the value of its previous execution, it will always evaluate to the same result. In your case, it will almost always make the API call.
Confusingly, this is not 100% reliable! You will find that sometimes, it will work as you intend. Though in my tests, this only happened around 1 times out of 10, and most often when the formulas updated when saving changes to the script editor.
Ideally, though not possible, you would want to be able to write something like this:
function checkTest() {
var cell = SpreadsheetApp.getActiveRange();
var cellValue = cell.getValue();
var adjacentCell = cell.offset(0, 1);
var status = adjacentCell.getValue();
if (status == "") {
cell.setValue(cellValue)
adjacentCell.setValue("executed")
}
}
Which would clear the formula once it has run, alas, setValue() is disabled for formulas! If you wanted to use setValue() you would need to run your script from a menu, trigger or the script editor. In which case it would no longer make sense as a formula.z
References
https://developers.google.com/apps-script/guides/sheets/functions

Implementing vlookup and match in function

I'm trying to create a function in Sheets that combines a "Vlookup" and "Match" combination that I use frequently.
I want to use my function, "Rates" to accept 1 argument and return a combination of Vlookup and Match, that always uses the same values.
Vlookup(argument, DEFINED RANGE (always stays the same defined range), match(A1 (always cell A1), DIFFERENT DEFINED RANGE, 0), FALSE)
I have tried creating a script, but have no experience coding, and I receive an error that "vlookup is not defined"
function ratesearch(service) {
return vlookup(service, Rates, Match($A$1,RatesIndex,0),FALSE);
}
Actual results: #ERROR!
ReferenceError: "vlookup" is not defined. (line 2).
function findRate() {
var accountName = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet().getRange(1,1).getValue(); //determine the account name to use in the horizontal search
var rateTab = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Rates'); //hold the name of the rate tab for further dissection
var rateNumColumns =rateTab.getLastColumn(); //count the number of columns on the rate tab so we can later create an array
var rateNumRows = rateTab.getLastRow(); //count the number of rows on the rate tab so we can create an array
var rateSheet = rateTab.getRange(1,1,rateNumRows,rateNumColumns).getValues(); //create an array based on the number of rows & columns on the rate tab
var currentRow = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet().getActiveCell().getRow(); //gets the current row so we can get the name of the rate to search
var rateToSearch = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet().getRange(currentRow,1).getValue(); //gets the name of the rate to search on the rates tab
for(rr=0;rr<rateSheet.length;++rr){
if (rateSheet[rr][0]==rateToSearch){break} ;// if we find the name of the
}
for(cc=0;cc<rateNumColumns;++cc){
if (rateSheet[0][cc]==accountName){break};
}
var rate = rateSheet[rr][cc] ; //the value of the rate as specified by rate name and account name
return rate;
}
Optimization points for Alex's answer:
Never forget to declare variables with var, const or let (rr and cc). If you omit the keyword, the variables will be global and cause you a lot of trouble (as they will not reset after the loop finishes). The best way is to use block-scoped let.
Following #1, do not rely on out-of-scope variables (rateSheet[rr][cc]).
You do not need to call SpreadsheetApp.getActiveSpreadsheet() multiple times - that's what variables are for. Call once, then reuse.
getRange(1,1,<last row>, <last col>) is equivalent to a single getDataRange call.
use find or findIndex method to avoid verbose loops.
With the points applied, you get a clean and optimized function to use:
const findRate = () => {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const accountName = ss.getActiveSheet().getRange(1, 1).getValue();
const rateTab = ss.getSheetByName("Rates");
const rates = rateTab.getDataRange().getValues();
const currentRow = ss.getActiveSheet().getActiveCell().getRow();
var rateToSearch = ss.getActiveSheet().getRange(currentRow, 1).getValue();
const rr = rates.findIndex((rate) => rate === rateToSearch);
const [firstRates] = rates;
const cc = firstRates.findIndex((rate) => rate === accountName);
return rates[rr][cc];
};
Note that the "vlookup" is not defined error indicates there is no vlookup variable / function declaration in scope. Which obviously is the case as there is no built-in Google Apps Script vlookup function.
You can't access random ranges from a custom function so you would have to provide the data to the function, some of the other solutions here that use get active spreadsheet won't work as a custom function which I am guessing is what the OP is looking for, here is an example of a script that does that but word of warning before you go down this road, custom functions are much slower than the built in functions so doing this will be much slower than vlookup and match, if you only have a few functions like this in the sheet you will be fine, but if you build large tables with dozens of rows that use custom functions it will slow down you spreadsheet substantially.
// Combines VLOOKUP and MATCH into a single function
// equivalent to VLOOKUP(rowValue, tableData, MATCH(columnName, tableHeader))
// but in this version tableData includes tableHeader
function findInTable(tableData, columnName, rowValue) {
if (rowValue === "") {
return "";
}
if (tableData.length == 0) {
return "Empty Table";
}
const header = tableData[0];
const index = header.indexOf(columnName);
if (index == -1) {
return `Can't find columnName: ${columnName}`;
}
const row = tableData.find(row => row[0] == rowValue);
if (row === undefined) {
return `Can't find row for rowValue: ${rowValue}`;
}
return row[index];
}
Another optimization I suggest you do is use named ranges, it allows you to transform something like:
=VLOOKUP(C5, 'Other Sheet'!A2:G782, MATCH("Column Name", 'Other Sheet'!A1:G1))
into a more readable and easier to look at:
=VLOOKUP(C5, someTableData, MATCH("Column Name", someTableHeader))
for the custom function form this will look like:
=findInTable(A1:G782, "Column Name", C5)
Note that I shorted the argument list by merging the data and header, this makes some assumptions about the table structure, e.g. that there is a one header line and that the lookup value is in the first column but it makes it even shorter and easier to read.
But as mention before this comes at the cost of being slower.
I ended up giving up on using this for my needs due to how slow it is and how much faster VLOOKUP and MATCH are since they are built in functions.
vlookup is not something you can use in a function in a script, it is a spreadsheet formula.
Google Scripts use JavaScript, so you'll need to write your code in JS then output it to a relevant cell.
If you could share your sheet/script we could help figure it out with you.

How to use the getRange() method with an array value as one of the parameters

I don't have much experience using Javascript but I'm developing a simple code to filter some information relevant to a professor I'm helping. I am searching the row number of a certain amount of data using a for and then I'm using an array to store all the rows that contain those words. Since I'm using Appscript, I only need to relocate a certain amount of data from the row I'm returning to a final row I've already know. My code is as follows:
if(cell === "Average")
{
index++;
initialcoords[index] = n; // n is the iteration variable in the for
}
I've tested the contents of the array and they are just fine, so I'm storing correctly the rows. The problem is that I'm using a different method to paste the data in a different sheet in Google Spreadhsheets. My code to do so is the following:
function pasteInfo()
{
var ss = SpreadsheetApp.getActiveSpreadsheet();
var source = ss.getSheetByName("Sheet 1");
var destination = ss.getSheetByName("Sheet 2");
var range = source.getRange(initialcoords[1], 1, 8, 3);
range.copyValuesToRange(destination, 4, 6, 4, 6);
}
My probelm is the getRange() since it prints an error like this:
can't find method getRange((class),number,number,number).
I believe that even if n is declared as an integer, the values that I'm returning are of a different type incompatible with the getRange() method. Could anyone help me to confirm this and to help me convert it to integer? I would really appreciate your help.
You first need to define the Sheet you want to get the data from since a Spreadsheet can have multiple Sheets.
You need to ensure you have appropriate default values defined before using the parameters, otherwise the interpreter will start making guess.
Provide defaults if parameters are empty:
function fillLine(row, column, length, bgcolor)
{
row = row || 0;
column = column || 0;
length = length || 1;
bgcolor = bgcolor || "red";
var sheet = SpreadsheetApp.getActiveSheet();
sheet.getRange(1+row, 1+column, 1, length).setBackground(bgcolor)
}
You may also try the solution offered by community: Can't get Google Scripts working