Google Apps Script - Better way to do a Vlookup - google-apps-script

I am doing a kind of VLOOKUP operation in a column with about 3K cells. I am using the following function to do it. I commented on what the code is doing in the function, but to summarize:
It creates a map from values to search for from a table with metadata
It iterates each value of a given range, and searches for coincidences in the previous map
If coincidences are found, it uses the index to capture the second column of the metadata table
Finally, sets the value captured in another cell
This is the code:
function questions_categories() {
var ss = SpreadsheetApp.getActive();
var sheet = ss.getSheetByName("data_processed");
// get metadata. This will work as the table to look into
// Column B contains the matching element
// Column C contains the string to return
var metadata = ss.getSheetByName("metadata").getRange('B2:C').getValues()
// Just get the different values from the column B
var dataList = metadata.map(x => x[0])
// Used to define the last cell where to apply the vlookup
var Avals = sheet.getRange("A1:A").getValues();
var Alast = Avals.filter(String).length;
// define the range to apply the "vlookup"
const questions_range = sheet.getRange("Q2:Q" + Alast);
forEachRangeCell(questions_range, (cell) => {
var searchValue = cell.getValue();
// is the value to search in the dataList we defined previously?
var index = dataList.indexOf(searchValue);
if (index === -1) {
// if not, throw an error
throw new Error('Value not found')
} else {
// if the value is there, use the index in which that appears to get the value of column C
var foundValue = metadata[index][1]
// set the value in two columns to the right
cell.offset(0, 2).setValue(`${foundValue}`);
}
})
}
forEachRangeCell() is a helper function to iterate through the range.
This works very well, but it resolves 3-4 cells per second, which is not very efficient if I need to check thousands of data. I was wondering if there is a more performant way to achieve the same result.

To improve performance, use Range.setValues() instead of Range.setValue(), like this:
function questions_categories() {
const ss = SpreadsheetApp.getActive();
const source = { values: ss.getRange('metadata!B2:C').getValues() };
const target = { range: ss.getRange('data_processed!Q2:Q') };
source.keys = source.values.map(row => row[0]);
target.keys = target.range.getValues().flat();
const result = target.keys.map(key => [source.values[source.keys.indexOf(key)]?.[1]]);
target.range.offset(0, 2).setValues(result);
}
See Apps Script best practices.

Related

Google Sheets appendRow and add values to specific columns

I have developed a sidebar form that appends values to an existing spreadsheet.
The form works; however, the new values are added starting at column A. I would like to adapt my code to select the columns to which appended values are inserted. The backend code used to append data is:
function addNewRow(rowData) {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const ws = ss.getSheetByName("Recruitment_Contacts");
ws.appendRow([rowData.LnRT,rowData.FnRT,rowData.Gn,rowData.St,rowData.Dtr,rowData.Trn,rowData.Td]);
return true;
}
The code used within HTML script to add data after the submit button is selected is:
function afterButtonClicked(){
var ln = document.getElementById("LnRT");
var fn = document.getElementById("FnRT");
var gn = document.getElementById("Gn");
var st = document.getElementById("St");
var dtr = document.getElementById("Dtr");
var trn = document.getElementById("Trn");
var td = document.getElementById("Td");
var rowData = {LnRT: ln.value, FnRT: fn.value, Gn: gn.value, St: st.value, Dtr: dtr.value, Trn: trn.value, Td: td.value};
google.script.run.withSuccessHandler(afterSubmit).addNewRow(rowData);
}
Any support that the community can provide would be greatly appreciated.
Thank you.
I think this is what you're looking for:
Selecting content by id
function addNewRow(rowData) {
const ss=SpreadsheetApp.getActive();
const sh=ss.getSheetByName("Recruitment_Contacts");
const hA=sh.getRange(1,1,1,sh.getLastColumn()).getValues()[0];
idx={};
hA.forEach(function(h,i){idx[i]=h;});
ws.appendRow([rowData[idx[0]],rowData[idx[1]],rowData[idx[2]],rowData[idx[3]],rowData[idx[4]],rowData[idx[5]],rowData[idx[6]]]);
return true;
}
I do this a lot and I just hide the top row and it doesn't seem to bother anybody and warn them that if the screw it up I get to come and charge more for fixing it.
Solution
To achieve what you are aming for of appending a new row from the column index you want, you must use the Apps Script method setValues on the right range. Here is the code implementation with self explanatory comments to achieve this:
// you can pass a column index to this function as a new parameter (or inside rowData)
function addNewRow(rowData,column) {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const ws = ss.getSheetByName("Recruitment_Contacts");
// Set as the data what we want to insert to then be able to know how many
// columns will this fill
// We need to wrap the data into [] as it is expecting a nested array for [cols] and [rows]
var data = [[rowData.LnRT,rowData.FnRT,rowData.Gn,rowData.St,rowData.Dtr,rowData.Trn,rowData.Td]];
// Set the values on the first row of the sheet (reproducing the behaviour of appendRow)
// and starting from the row index you want (5 for example).
// It will be 1 row and the length of the data
// long in terms of columns.
ws.insertRows(1,1);
ws.getRange(1, column,1,data[0].length).setValues(data);
return true;
}
I hope this has helped you. Let me know if you need anything else or if you did not understood something. :)
To resolve my issue, I created a server side function called "recruitInfo" and listed all the columns in my worksheet. I then called that function in my HTML code. The video I used to walk me through this process is Web App - Google Sheets CRUD, Part 5
function addRecruit(recruitInfo){
const ss = SpreadsheetApp.getActiveSpreadsheet();
const ws = ss.getSheetByName("sheetName");
const uniqueIds = ws.getRange(2, 1, ws.getLastRow()-1, 1).getValues();
var maxNum = 0;
uniqueIds.forEach(r => {
maxNum = r[0] > maxNum ? r[0] : maxNum
});
var newID = maxNum + 1;
ws.appendRow([newID,
recruitInfo.lastName,
recruitInfo.firstName])
}
function addRecruit(){
var recruitInfo = {};
recruitInfo.firstName = document.getElementById("firstName").value;
recruitInfo.lastName = document.getElementById("lastName").value;
google.script.run.withSuccessHandler(function(){}).addRecruit(recruitInfo);
}

Deleting sheets not found in lookup

I am trying to delete unneeded sheets from a template once relevant information has been copied across. To do this I am looking up the sheet name with a check list. If the lookup returns a value of 0.0 then I want to delete the sheet.
function myFunction() {
var studentsheet = SpreadsheetApp.openById('1Qj9T002nF6SbJRq-iINL2NisU7Ld0kSrQUkPEa6l31Q').;
var sheetsCount = studentsheet.getNumSheets();
var sheets = studentsheet.getSheets();
for (var i = 0; i < sheetsCount; i++){
var sheet = sheets[i];
var sheetName = sheet.getName();
Logger.log(sheetName);
var index = match(sheetName);
Logger.log(index);
if (index = "0.0"){
var ss = studentsheet.getSheetByName(sheetName).activate();
ss.deleteactivesheet();
}
else {}
}
function match(subject) {
var sourcesheet = SpreadsheetApp.openById('14o3ZG9gQt9RL0iti5xJllifzNiLuNxWDwTRyo-x9STI').getSheetByName("Sheet6").activate();
var lookupvalue = subject;
var lookuprange = sourcesheet.getRange(2, 2, 14, 1).getValues().map(function(d){ return d[0] });
var index = lookuprange.indexOf(subject)+1;
return index;
}
};
The problem is at the end when trying to delete the sheet. I have amended the code so it selects the sheet and makes it active but in the next line I am not allowed to call .deleteactivesheet(). Does anyone know how I can write this end part where I can select the sheet based on the index score being 0 and then delete it?
To delete a Sheet from a Spreadsheet, there are two applicable Spreadsheet class methods (as always, spelling and capitalization matter in JavaScript):
Spreadsheet#deleteSheet, which requires a Sheet object as its argument
Spreadsheet#deleteActiveSheet, which takes no arguments
The former is suitable for any type of script, and any type of trigger, while the latter only makes sense from a bound script working from a UI-based invocation (either an edit/change trigger, menu click, or other manual execution), because "activating" a sheet is a nonsense operation for a Spreadsheet resource that is not open in a UI with an attached Apps Script instance.
The minimum necessary modification is thus:
var index = match(sheet);
if (index === 0) { // if Array#indexOf returned -1 (not found), `match` returned -1 + 1 --> 0
studentsheet.deleteSheet(sheet);
}
A more pertinent modification would be something like:
function deleteNotFoundSheets() {
const studentWb = SpreadsheetApp.openById("some id");
const lookupSource = getLookupRange_(); // assumes the range to search doesn't depend on the sheets that may be deleted.
studentWb.getSheets().filter(function (s) {
return canDelete_(lookupSource, s.getName());
}).forEach(function (sheetToDelete) {
studentWb.deleteSheet(sheetToDelete);
});
}
function getLookupRange_() {
const source = SpreadsheetApp.openById("some other id");
const sheet = source.getSheetByName("some existing sheet name");
const r = sheet.getRange(...);
return r.getValues().map(...);
}
function canDelete_(lookupRange, subject) {
/** your code that returns true if the subject (the sheet name) should be deleted */
}
This modification uses available Array class methods to simplify the logic of your code (by removing iterators whose only purpose is to iterate, and instead expose the contained values to the anonymous callback functions). Basically, this code is very easily understood as "of all the sheets, we want these ones (the filter), and we want to do the same thing to them (the forEach)"
Additional Reading:
JavaScript comparison operators and this (among others) SO question
Array#filter
Array#forEach
If just like me you have been struggling to find a working example of #tehhowch above solution, here it is:
function deleteSheetsWithCriteria() {
let ss = SpreadsheetApp.getActiveSpreadsheet(),
sheetList = ss.getSheetByName('List'),
list = sheetList.getRange('A1:A'+sheetList.getLastRow()),
lookupRange = list.getValues().map(function(d){ return d[0] }) + ',List'; // List of sheets NOT to delete + lookuprange sheet
Logger.log(lookupRange)
//Delete all sheets except lookupRange
ss.getSheets().filter(function (sheet) {
return deleteCriteria_(lookupRange, sheet.getName());
}).forEach(function (sheetToDelete) {
ss.deleteSheet(sheetToDelete);
});
}
function deleteCriteria_(lookupRange, sheet) {
var index = lookupRange.indexOf(sheet);
Logger.log(index)
if (index > 0) {0} else {return index}; // change line 19 to 'return index;' only, if you want to delete the sheets in the lookupRange, rember to remove the lookupRange in variable LookupRage
}

What is the most efficient way to clear row if ALL cells have a value with Apps Script?

I'm trying to come up with a function that will clear contents (not delete row) if all cells in a range have values. The script below isn't functioning as expected, and I would really appreciate any help/advice you all have. It's currently only clearing out a single line, and doesn't appear to be iterating over the whole dataset. My thought was to iterate over the rows, and check each cell individually. If each of the variables has a value, clear that range and go to the next row.
Here's a link to a sample Google Sheet, with data and the script in Script Editor.
function MassRDDChange() {
// Google Sheet Record Details
var ss = SpreadsheetApp.openById('1bcrEZo3IkXiKeyD47C_k2LIRy9N9M6SI2h2MGK1Cj-w');
var dataSheet = ss.getSheetByName('Data Entry');
// Initial Sheet Values
var newLastColumn = dataSheet.getLastColumn();
var newLastRow = dataSheet.getLastRow();
var dataToProcess = dataSheet.getRange(2, 1, newLastRow, newLastColumn).getValues().filter(function(row) {
return row[0]
}).sort();
var dLen = dataToProcess.length;
// Clear intiial sheet
for (var i = 0; i < dLen; ++i) {
var row = 2;
var orderNumber = dataToProcess[i][0].toString();
var rdd = dataToProcess[i][1].toString();
var submittedBy = dataToProcess[i][2].toString();
var submittedOn = dataToProcess[i][3].toString();
if (orderNumber && rdd && submittedBy && submittedOn) {
dataSheet.getRange(row, 1, 1, newLastColumn).clear();
row++;
} else {
row++; // Go to the next row
continue;
}
}
}
Thanks!
Since you don't want to delete the rows, just clear() them, and they're all on the same worksheet tab, this is a great use case for RangeLists, which allow you to apply specific Range methods to non-contiguous Ranges. Currently, the only way to create a RangeList is from a an array of reference notations (i.e. a RangeList is different than an array of Range objects), so the first goal we have is to prefix our JavaScript array of sheet data to inspect with a usable reference string. We could write a function to convert array indices from 0-base integers to A1 notation, but R1C1 referencing is perfectly valid to pass to the RangeList constructor, so we just need to account for header rows and the 0-base vs 1-base indexing difference.
The strategy, then, is to:
Batch-read sheet data into a JavaScript Array
Label each element of the array (i.e. each row) with an R1C1 string that identifies the location where this element came from.
Filter the sheet data array based on the contents of each element
Keep elements where each sub-element (the column values in that row) converts to a boolean (i.e., does not have the same value as an empty cell)
Feed the labels of each of the kept rows to the RangeList constructor
Use RangeList methods on the RangeList
Because this approach uses only 3 Spreadsheet calls (besides the initial setup for a batch read), vs 1 per row to clear, it should be considerably faster.
function clearFullyFilledRows() {
// Helper function that counts the number of populated elements of the input array.
function _countValues(row) {
return row.reduce(function (acc, val) {
var hasValue = !!(val || val === false || val === 0); // Coerce to boolean
return acc + hasValue; // true == 1, false == 0
}, 0);
}
const sheet = SpreadsheetApp.getActiveSheet();
const numHeaderRows = 1,
numRows = sheet.getLastRow() - numHeaderRows;
const startCol = 1,
numCols = sheet.getLastColumn();
// Read all non-header sheet values into a JavaScript array.
const values = sheet.getSheetValues(1 + numHeaderRows, startCol, numRows, numCols);
// From these values, return a new array where each row is the origin
// label and the count of elements in the original row with values.
const labeledCounts = values.map(function(row, index) {
var rNc = "R" + (numHeaderRows + 1 + index) + "C";
return [
rNc + startCol + ":" + rNc + (startCol + numCols - 1),
_countValues(row)
];
});
// Filter out any row that is missing a value.
const toClear = labeledCounts.filter(function (row) { return row[1] === numCols; });
// Create a RangeList from the first index of each row (the R1C1 label):
const rangeList = sheet.getRangeList(toClear.map(function (row) { return row[0]; }));
// Clear them all:
rangeList.clear();
}
Note that because these cleared rows are possibly disjoint, your resulting sheet may be littered with rows having data, and rows not having data. A call to sheet.sort(1) would sort all the non-frozen rows in the sheet, moving the newly-empty rows to the bottom (yes, you can programmatically set frozen rows). Depending how this sheet is referenced elsewhere, that may not be desirable though.
Additional references:
Array#filter
Array#reduce
Array#map
JavaScript Logical Operators
JavaScript Comparison Operators

Automatically add variables to array?

In a google script I have written something to check my monthly expenses, which are listed in a google sheet.
Based on words the script finds, every line gets a category tag. It works fine, but the number of words to search for is getting big. And the array is getting big too.
I have listed 6 pairs (words to find, tag to add) - but in real version I have as many as 35. How can I create the pairs, and load everything automatically in the array?
This is my script:
function myFunction() {
// check usual suspects
var A1 = ["CAFE", "HORECA"]
var A2 = ["ALBERT", "AH"]
var A3 = ["VOMAR","Vomar"]
var A4 = ["HEMA","HEMA"]
var A5 = ["KRUID","Drogist"]
var A6 = ["RESTA", "Horeca"]
// in Array
var expenses = [A1,A2,A3,A4,A5,A6]
var ss = SpreadsheetApp.getActiveSheet();
var data = ss.getDataRange().getValues(); // read all data in the sheet
for (i in expenses)
{for(n=0;n<data.length;++n){ // iterate row by row and examine data in column A
if(data[n][3].toString().toUpperCase().match(expenses[i][0])==expenses[i][0]){ data[n][4] = expenses[i][1]};
// if column D contains 'xyz' then set value in index [5] (is column E)
}
Logger.log(data)
ss.getRange(1,1,data.length,data[0].length).setValues(data); // write back to the sheet
}
}
I can propose you that:
function multiPass(){
var searchCriterions = [
["CAFE","HORECA" ],
["ALBERT", "AH"],
["VOMAR","Vomar"],
["HEMA","HEMA"]
];
var dico = {};
var patt = "";
for (var i in searchCriterions) {
dico[searchCriterions[i][0]] = searchCriterions[i][1];
patt += "("+searchCriterions[i][0]+")";
if((Number(i)+1)<searchCriterions.length){
patt += "|";
}
}
var re = new RegExp(patt,"");
var ss = SpreadsheetApp.getActiveSheet();
var data = ss.getDataRange().getValues(); // read all data in the sheet
Logger.log(re);
for(n=0;n<data.length;++n){ // iterate row by row and examine data in column A
// THAT'S NOT COLUMN "A", 3 --> "D"
var test = data[n][3].toString().toUpperCase().match(re);
Logger.log(test);
if(test!==null){
data[n][4] = dico[test[0]]
};
}
ss.getRange(1,1,data.length,data[0].length).setValues(data); // write back to the sheet
}
instead of using variable for your "pairs" prefer to use a big table (it's less painfull to write)
then transform your pairs in object to quickly access the second argument of the pair and create a big regexp that check at once all the keywords instead of parsing them one by one.
Now as we are using a big array as search criterions we can totally imagine that this big array is loaded instead of hard coding it. If you have a sheet where the data is you can change the code this way:
var searchCriterions = SpreadsheetApp.getActive().getRange("namedRange").getValues();

Spreadsheet formula taking too long to run - script advise

The following formula is taking far to long to run.
= TRANSPOSE (
IFERROR (
INDEX (
FILTER(Students!B:B;
REGEXMATCH(Students!B:B; C50),
REGEXMATCH(Students!B:B; B50),
REGEXMATCH(Students!C:C; F50)
));"NO MATCH"
))
Any suggestions on the coding would be great, as I know very little programming.
Thanks
T
Here is a custom function that can replace the formula you're using. For example:
=listStudents(C50,B50,F50)
If used that way, you'll still have constant recalculation, but it should be much faster than the regex tests. Alternatively, the same function could be invoked from a menu item, and used to populate a given target range in the sheet, thereby avoiding automatic recalculation altogether.
Code:
/**
* Custom spreadsheet function to produce a list of names of
* students that match the given criteria.
*/
function listStudents( givenName, surname, employer ) {
var matches = []; // matching students will be placed in this array
var HEADERS = 1; // # rows of header info at top of sheet
var FULLNAME = 1; // Column containing full names (B)
var EMPLOYER = 2; // employers (C)
// Array filter function - returns true if conditions match
function test4match( row ) {
return ( row[FULLNAME].indexOf(givenName) !== -1 &&
row[FULLNAME].indexOf(surname) !== -1 &&
row[EMPLOYER].indexOf(employer) !== -1)
}
var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Students');
var range = sheet.getDataRange();
var data = range.getValues().slice(HEADERS); // All data from sheet, without headers
var filteredData = data.filter(test4match); // Get matching rows
for (var i=0; i<filteredData.length; i++) {
matches.push(filteredData[i][FULLNAME]); // Then produce list of names
}
return [matches]; // Return a 2-d array, one row
}