How do you loop through disparate control-selected cells? - google-apps-script

I am writing a script that would normalize the selected phone numbers in a Google spreadsheet:
/**
* Normalizes phone numbers to ###-###-#### format.
*/
function normalizePhoneNumbers() {
var activeSheet = SpreadsheetApp.getActiveSheet();
var activeRange = activeSheet.getActiveRange();
var selectedCells = activeRange.getValues();
var phoneNumber = "";
for (var i = 0; i < selectedCells.length; i++) {
for (var j = 0; j < selectedCells[i].length; j++) {
phoneNumber = selectedCells[i][j]; // for better readability
if (phoneNumber) {
phoneNumber = phoneNumber.replace(/[^\d]/g, ""); // remove all non-digit characters from phone number
} else {
continue;
}
if (phoneNumber.length === 10) {
phoneNumber = phoneNumber.slice(0, 3) + '-' + phoneNumber.slice(3, 6) + '-' + phoneNumber.slice(6, 10);
} else {
continue;
}
selectedCells[i][j] = phoneNumber;
}
}
activeRange.setValues(selectedCells);
};
This works when I select a row or a column or a block of cells, but only changes one of the items if I control-select cells scattered around the spreadsheet. Is it possible to change multiple cells in a non-contiguous selection? If so, what am I doing wrong?

What you want to do is not exactly possible. For instance, activerange.setValues() expects a single 2D array (like what's returned by Range.getValues()). In either case your function could only get a single rectangular array of data, not a disjoint set of cells, hen you call getValues().
However, if you can deal with making this a custom function which is invoked within a spreadsheet (using something like =NORMALIZEPHONENUMBERS) then you can use map() as demonstrated under Optimization in the custom functions documentation.

Related

Seeking help in custom function returning a two-dimensional array that can overflow

To minimize the number of calls, I am trying to use a single call to a function and populate a range (with multiple cells). My function 'getTotal' below is working fine but I am stuck in 'You do not have permission to call setValue'. The range I am trying to populated is C4: C12 (please see attached screen) and I am passing =getTotal(A4: A12, B4: B12) to the function. The function is called from C16.
We can't call setValue from a custom function but I am not sure whether I can return a two-dimensional array that can overflow into the C4: C12. Documentations and answers here did not help me so I am seeking help.
function getTotal(oSetVal, vRange) {
if (Array.isArray(oSetVal[0]) && Array.isArray(vRange[0])) {
var asp = SpreadsheetApp.getActiveSpreadsheet();
if (!asp) {
return;
}
var as = asp.getActiveSheet();
var ss = asp.getSheetByName("Data")
if (!ss) {
return;
}
if (Array.isArray(oSetVal[0]) && Array.isArray(vRange[0])) {
var activeCol = as.getActiveCell().getColumn();
var numCol = activeCol-1
var textCol = activeCol-2
var drRow = as.getActiveCell().getRow()-4;
var textRowStart = drRow - 15;
var cSource = ss.getRange(3, 2, 5000, 1).getValues();
var cKcal = ss.getRange(3, 4, 5000, 1).getValues();
var vEnergy = 0;
if (textRowStart < 3){
textRowStart = 3;
}
var textRowEnd = drRow
var textSource = as.getRange(textRowStart, textCol, textRowEnd, 1)
var numSource = as.getRange(textRowStart, numCol, textRowEnd, 1)
var numSource = as.getRange(textRowStart, numCol, textRowEnd, 1)
var setSource = as.getRange(textRowStart, activeCol, textRowEnd, 1)
for (var k = 1; k <= textRowEnd-textRowStart; k++) {
var tVal = textSource.getCell(k,1).getValue();
var nVal = numSource.getCell(k,1).getValue();
if (tVal.length > 0 && isNumericVal(nVal)) {
for (var i = 0; i < cSource.length; i++) {
if(cSource[i][0] == tVal){
var ckl = cKcal[i][0];
var vEn = parseFloat((ckl * nVal / 100))
vEn = +vEn.toFixed(1)
vEnergy = vEnergy + vEn
setSource.getCell(k,1).setValue(vEn);
}
}
}
}
}
return vEnergy
}
}
Thanks
If you want to keep using a custom function:
You need to use return values instead of setValues(). You are not allowed to set the value of arbitrary cells from a custom formula.
Also, as pointed out by #Cooper "You can return a 2D array, but it can only overflow down and right. Not up and to the left". Meaning you cannot overflow upwards, but only downwards.
This means that you need to call your function from the cell C4 rather than from C16.
In order to modify your formula and enable overflow, you need to create a 2D-array of the required dimensions.
In your case 1 column and 9 rows will set all vEn values. You could also use 13 rows if you want to set both vEn and vEnergy in one go.
Check here for a sample on how to do this.
Applying the sample to your case:
You can define at the beginning of your function:
var myArray = [];
and then replace the inside of your for loop (setSource.getCell(k,1).setValue(vEn);) with
myArray[k] = [];
myArray[k].push(vEn);
And finally replace return vEnergy with:
myArray[12].push(vEnergy);
return myArray;
I did not test your code given that I do not have access to "Data", but I hope that my answer helps you to modify your code in the desired way.
A custom function cannot affect cells other than those it returns a value to. In other words, a custom function cannot edit arbitrary cells, only the cells it is called from and their adjacent cells. To edit arbitrary cells, use a custom menu to run a function instead.
You can return a 2D array, but it can only overflow down and right. Not up and to the left.

Counting cells with particular background color in Google Sheets

I am trying to produce a visual rota for my restaurant using Google Sheets. I need to count the background color of a row in order to get a figure for the number of half hour periods worked that day.
Here is a link to the google sheet:
https://docs.google.com/spreadsheets/d/19IEDGZypi3nVt55-OayvPo__pbV0sTuRQ3wCJZ1Mhck/edit?usp=sharing
And the script that I am using:
function countBG(range, colorref) {
var sheet = SpreadsheetApp.getActiveSheet();
var color = sheet.getRange(colorref).getBackground();
var range = sheet.getRange(range);
var rangeVal = range.getValues();
var count = 0;
var allColors = range.getBackgrounds();
for (var i = 0; i < allColors.length; i++) {
for (var j = 0; j < allColors[0].length; j++) {
if (allColors[i][j] == color) count += 1;
};
};
return count;
}
I find that the script works the first time it's run, but after that it gives an error:
Range not found (line 4, file "Code")
Would appreciate any help in getting this working, I'm new at Google Sheets and scripting so possibly missing something obvious.
Thanks,
DB.
If you wanted to run it as a custom function and just use a single row as the input, this will work.
You'd pass the row variables like so =countBG("D6:AH6")
function countBG(input) {
var sheet = SpreadsheetApp.getActiveSheet();
var colors = sheet.getRange(input).getBackgrounds();
var count = 0;
Logger.log(colors);
for (var i = 0; i < colors.length; i++) {
for (var j = 0; j < colors[0].length; j++) {
if (colors[i][j] != "#ffffff") count += 1;
};
};
Logger.log(count);
return count;
}
Example in the sheet here in column AI
For some reason SpreadsheetApp.getActiveSheet().getRange(...) gives me only a matrix of values, not an actual range.
My solution (notice how the range is a String here):
function countColoredCellsInRange(countRange, expectedBackground) {
var range = SpreadsheetApp.getActiveSpreadsheet().getRange(countRange);
var backgrounds = range.getBackgrounds();
var coloredCellsAmount = 0;
for (var i = 0; i < backgrounds.length; ++i) {
for (var j = 0; j < backgrounds[i].length; ++j) {
var currentBackground = backgrounds[i][j];
if (currentBackground == expectedBackground) {
++coloredCellsAmount;
}
}
}
return coloredCellsAmount;
};
Example usage:
=countColoredCellsInRange("Sheet!A2:A" ; "#00ff00")
Also, here is example of it at work (calculating percentages in "Progress graph" sheet).
Here is a function I put together for something similar. Rather than a hard-coded value, or needing to know the color beforehand; the first input is the cell that you want to grab the color from.
/**
* Counts the number of cells with the given color within the range.
*
* #param {"B32"} target_color_cell Cell reference to extract the color to count.
* #param {"A1:F22"} range_count_color Range to cycle through and count the color.
* #returns integer The count of the number of cells within range_count_color
* which match the color of target_color_cell.
* #customfunction
*/
function count_cells_by_color( target_color_cell, range_count_color ) {
if (typeof target_color_cell !== "string")
throw new Error( "target_color_cell reference must be enclosed in quotes." );
if (typeof range_count_color !== "string")
throw new Error( "range_count_color reference must be enclosed in quotes." );
var count=0;
var target_color = SpreadsheetApp.getActiveSheet().getRange( target_color_cell ).getBackground();
var range_colors = SpreadsheetApp.getActiveSheet().getRange( range_count_color ).getBackgrounds();
var t=""
for(var row=0; row<range_colors.length;row++){
for(var column=0;column<range_colors[row].length;column++){
let cell_color = range_colors[row][column];
if(cell_color == target_color){
count = count+1;
}
}
}
return count;
}
How to use this:
Create or Open up your Google Sheet.
At the top of the page navigate to Extensions -> App Scripts
Delete the sample that is there and paste in the code from the above block.
Click the "Save" button.
Within any cell of your spreadsheet where you now want to do this calculation enter the "=" button and then specify the function name "count_cells_by_color". For example the following will take the background color from cell A1, and count the number of times it appears in cells A1 through G15.
=count_cells_by_color("A1", "A1:G15")

Google Apps Script: how to copy paste ranges based on formulas?

I have a model in Google Sheets that is set up with one column per day. It contains both actuals and forecasts, and every day I need to roll forward formulas to replace forecasts with actuals. I can't roll forward the whole column, only a segment of it (there are reference numbers above and below that shouldn't be changed).
I have tried to write a script to do this for me every day, but I don't know how to make getRange reference a dynamic range. This is my attempt:
function rollColumn() {
var ss2 = SpreadsheetApp.openById('<ID redacted>');
ss2.getRange("=index(Model!$7:$7,,match(today()-2,Model!$4:$4,0)):index(Model!$168:$168,,match(today()-2,Model!$4:$4,0))").copyTo(ss2.getRange("=index(Model!$7:$7,,match(today()-1,Model!$4:$4,0)):index(Model!$168:$168,,match(today()-1,Model!$4:$4,0))"))
};
The INDEX formulas work insofar as they reference the relevant ranges (I have tested them in the spreadsheet). But clearly getRange doesn't accept formulas as an input. It also seems that Google Sheets doesn't allow for a named range to be created with formulas (which is how I would solve this in Excel).
Can someone help me recreate this functionality with GAS?
This is the closest existing question I've found on Stack Overflow, but I haven't been able to make it work:
Google Apps Script performing Index & Match function between two separate Google Sheets
Thank you!
You should add {contentsOnly:false} parameter to your code. something like this:
TemplateSheet.getRange("S2:T2").copyTo(DestSheet.getRange("S2:T"+LRow2+""), {contentsOnly:false});
Getting a date from column's title, then pasting formulas to the row to the right:
// note: we assume that sheet is disposed as in the following document: https://docs.google.com/spreadsheets/d/1BU2rhAZGOLYgzgSAdEz4fJkxEcPRpwl_TZ1SR5F0y08/edit?ts=5a32fcc5#gid=0
function find_3formulas() {
var sheet = SpreadsheetApp.getActiveSheet(),
leftTitle, // this variable will stay unused because we do not need a vertical index
topTitle = todayMinus_xDays(2),
topTitlesRange = sheet.getRange("G3:T3"),
leftTitlesRange = sheet.getRange("A4:A8"); // this range will stay unused.
var coor = findCoordinates(leftTitlesRange, leftTitle, topTitlesRange, topTitle);
if (coor.row == null || coor.column == null) {
sheet.getRange("M12:M14").setFormula('="NULL: please check logs"');
return;
}
var rowAxis = 4 + coor.row;
var colAxis = 8 + coor.column;
var fromRange = sheet.getRange(rowAxis, colAxis, 3, 1);
var toRange = sheet.getRange(rowAxis, colAxis + 1, 3, 1);
Logger.log(fromRange.getA1Notation())
Logger.log(toRange.getA1Notation());
var threeFormulas = fromRange.getFormulas();
toRange.setFormulas(threeFormulas)
}
// unused in current script!
function findCoordinates(leftTitlesRange, leftTitle, topTitlesRange, topTitle) {
var formattedDate,
row = 0,
column = 0;
if (leftTitle) {
row = findRow(leftTitlesRange, leftTitle);
}
if (topTitle) {
column = findColumn(topTitlesRange, topTitle);
}
var array = {row:row, column:column}
return array;
}
// unused in current script!
function findRow(range, valueToSearch) {
var colRows = range.getValues();
for (i = 0; i < colRows.length; i++) {
if (valueToSearch == colRows[i][0]) {return i;}
}
// however, if found nothing:
Logger.log("the value " + valueToSearch + " could not be found in row titles");
return null;
}
// assumes that column titles are dates, therefore of type object.
function findColumn(range, valueToSearch) {
var colTitles = range.getValues();
for (i = 0; i < colTitles[0].length; i++) {
if (typeof colTitles[0][i] == "object") {
formattedDate = Utilities.formatDate(colTitles[0][i], "GMT", "yyyy-MM-dd")
};
if (valueToSearch === formattedDate) {return i;}
}
// however, if found nothing:
Logger.log("today's date, " + valueToSearch + ", could not be found in column titles");
return null;
}
// substracts 2 days from today, then returns the result in string format.
function todayMinus_xDays(x) {
var d = new Date();
d = new Date(d - x * 24 * 60 * 60 * 1000);
d = Utilities.formatDate(d, "GMT", "yyyy-MM-dd");
return d;
}

custom spreadsheet function - get a1 notation for the range?

I'm trying to create a custom function for a google sheet that will find the rightmost string in a 1d range of cells, then return a header (in a specified row).
Here's where I'm stuck. I can get the string for that cell with the following code:
function FarRightHeader(range, rownumber) {
var cells = range[0].length;//gets the number of cells
for (var i = 0; i < cells; i++) { //loop through the cells in the range
var j = cells - 1 - i; // j will start at the end so the loop can work from left to right
if (range[0][j] != "") { //if the cell contains something
break; //jump out of the loop
}
}
var activeCell = SpreadsheetApp.getActiveRange().getA1Notation();
var activeColumn = activeCell.charAt(0);
var FarRightCell = "Hi, I'm___" + range[0][j] + "___ and I'm in column " + activeColumn;
return FarRightCell;
}
here's the glitch - the activeCell variable is taking the cell from which the custom function is called, not the far right populated cell in the range. I understand why this is happening, but don't know how to get the column I want.
To me it appears that the function is treating the range as simply the values in the cells divorced from what cells they actually are in the spreadsheet.
Is there a way to get information about the range within the spreadsheet that the function takes as a parameter?
Thanks in advance for any help or leads you can give me!
I see no glitch, you're imagining your loop as searching the cells, while you're just searching an Array that you got from the cells values.
But as your code sugests you don't need to retrieve the column like, you already have it, saved in the j, you just need to convert it to a letter, here's a code I fetched:
function colName(n) {
var ordA = 'a'.charCodeAt(0);
var ordZ = 'z'.charCodeAt(0);
var len = ordZ - ordA + 1;
var s = "";
while(n >= 0) {
s = String.fromCharCode(n % len + ordA) + s;
n = Math.floor(n / len) - 1;
}
return s;
}
Also here's a suggested better for loop:
var column; // Initialize before so if doesn't become private scope
for (column = ( range[0].length - 1 ); column > 0; column--) {
if (range[0][j] != "")
break;
}
Note: This requires that the Range starts at "A" column, if it doesn't you have to add the range first column to column.

Delete a row in google spreadsheet base on contents of cell in row

i have this script to delete certain rows, if the selected cell in the selected colum has the metioned content but i don't understand where it fails
function DeleteRowByKeyword() {
var value_to_check = Browser.inputBox("Enter the keyword to trigger delete Row","", Browser.Buttons.OK);
// prendo quello attivo
var DATA_SHEET = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
var FIRST_COLUMN = Browser.inputBox("Number of Column to look at (eg: for column A enter 1)","", Browser.Buttons.OK);
ss.toast("removing duplicates...","",-1);
var dataCopy1 = DATA_SHEET.getDataRange().getValues();
var deleted_rows = 0;
var rangeToCopy = '';
if (dataCopy1.length > 0) {
var i = 1;
while (i < DATA_SHEET.getMaxRows() - deleted_rows) {
if ((dataCopy1[i][FIRST_COLUMN]).search(value_to_check) != -1) {
ss.deleteRow(i);
deleted_rows++;
}
i++;
}
}
ss.toast("Done! " + deleted_rows + ' rows removed',"",5);
}
thanks in advance for any help
There are a few things to be improved:
Remember that spreadsheet rows and columns are numbered starting at
1, for all methods in the SpreadsheetApp, while javascript arrays
start numbering from 0. You need to adjust between those numeric
bases when working with both.
The String.search() method may be an inappropriate choice here, for
two reasons. The .search() will match substrings, so
('Testing').search('i') finds a match; you may want to look for exact
matches instead. Also, .search() includes support for regex
matching, so users may be surprised to find their input interpreted
as regex; .indexOf() may be a better choice.
To limit operations to rows that contain data, use .getLastRow()
instead of .getMaxRows().
When deleting rows, the size of the spreadsheet dataRange will get
smaller; you did take that into account, but because you're looping
up to the maximum size, the code is complicated by this requirement.
You can simplify things by looping down from the maximum.
The input of a Column Number is error-prone, so let the user enter a
letter; you can convert it to a number.
You had not defined a value for ss (within this function).
Here's the updated code:
function DeleteRowByKeyword() {
var value_to_check = Browser.inputBox("Enter the keyword to trigger delete Row", "", Browser.Buttons.OK);
var matchCol = Browser.inputBox("Column Letter to look at", "", Browser.Buttons.OK);
var FIRST_COLUMN = (matchCol.toUpperCase().charCodeAt(0) - 'A'.charCodeAt(0) + 1); // Convert, e.g. "A" -> 1
// prendo quello attivo (get active sheet)
var ss = SpreadsheetApp.getActiveSpreadsheet();
var DATA_SHEET = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
ss.toast("removing duplicates...", "", -1);
var dataCopy1 = DATA_SHEET.getDataRange().getValues();
var deleted_rows = 0;
if (dataCopy1.length > 0) {
var i = DATA_SHEET.getLastRow(); // start at bottom
while (i > 0) {
if (dataCopy1[i-1][FIRST_COLUMN-1] === value_to_check) {
ss.deleteRow(i);
deleted_rows++;
}
i--;
}
}
ss.toast("Done! " + deleted_rows + ' rows removed', "", 5);
}
You need to ensure that the index for deletion is the correct index. When you delete a row all bellow rows has their indexes changed -1.
So try this code:
if (dataCopy1.length > 0) {
var i = 1;
while (i < DATA_SHEET.getMaxRows() - deleted_rows) {
if ((dataCopy1[i][FIRST_COLUMN]).search(value_to_check) != -1) {
ss.deleteRow(i - deleted_rows);
deleted_rows++;
}
i++;
}
}