Update script cell references when columns are moved - google-apps-script

We're migrating a lot of our business logic to scripts behind the scenes, but I'm worried that they'll be much more fragile when columns move.
On Sheet Updates Automagically
For example, If I have a formula on a spreadsheet like this:
=If(A1=5,"Yes","No")
And then I Insert 1 Column Left of A, the formula will be automatically updated like this:
=If(B1=5,"Yes","No")
Apps scripts doesn't update
For example, if I have the formula in the script section:
function myFunction() {
var value = SpreadsheetApp.getActiveSheet().getRange("A1").getValue();
var output = (value == 5) ? 'Yes' : 'No';
Logger.log(output);
}
It will not update when the sheet changes.
Q: How can I get stable references in the code behind for columns that could potentially move?

This is a general problem when hardcoding strings or numbers in code.
In general the javascript parser can't tell which strings might be used on a sheet function call. Its sometimes not trivial to solve.
Two approaches are:
If the columns/cells/ranges are known beforehand, use named ranges:
Define a named range and use NamedRange in code. Use the range to directly write to it or query its row/column position.
Another for column based ranges like yours is that your code does this naming manually by using the column header as the column names. Code uses those names and reads the header to build the mapping.

Related

Range.SetValues() does not insert data on one sheet, on the other works. What is the reason?

I have a GoogleSheet with basically two sheets, which are very similar in terms of data collected.
I need to calculate same values for both sheets, but source data is in different columns.
Therefore I created three files in AppsScript:
Common.gs - with common function definitions
sheet1.gs
sheet2.gs - both sheet1 and sheet2 have only definitions of proper ranges in particular columns and one function to run script, which essentially calls functions defined in Common.gs, like so in sheet1.gs:
var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("sheet1")
var createdColumn = sheet.getRange("E2:E").getValues()
var ackColumn = sheet.getRange("G2:G").getValues()
var resColumn = sheet.getRange("I2:I").getValues()
var timeToAckColumn = sheet.getRange(2,14,ackColumn.length,1)
var timeToResColumn = sheet.getRange(2,15,resColumn.length,1)
var yearAndWeekRange = sheet.getRange(2,16,createdColumn.length,2)
function calculateMetricsSheet1() {
calculateTimeDiff(createdColumn, ackColumn, timeToAckColumn)
calculateTimeDiff(ackColumn, resColumn, timeToResColumn)
calculateWeek(createdColumn, yearAndWeekRange)
}
example function implementation (they are basically very similar with minor differences):
function calculateWeek(createdColumn, yearAndWeekRange) {
var arrData = []
for(var i=0;i<createdColumn.length;i++) {
if(createdColumn[i][0].toString()=="") {
arrData.push(["",""])
continue
}
var createdDate = new Date(createdColumn[i][0])
var year = createdDate.getFullYear()
var week = Utilities.formatDate(createdDate, "GMT+1", "w")
arrData.push([year, week])
}
yearAndWeekRange.setValues(arrData)
}
the sheet2.gs is basically different column definitions, the functions called within calculateMetricsSheet2() are the same.
So what is the problem?
The script works perfectly fine for sheet2.gs, but for sheet1.gs it does collect proper data, calculates proper data, but the data does not appear in proper columns after Range.setValues() call.
No exceptions or errors appear in the console.
Documentation does not provide any kind of information what could be the problem.
I have really ran out of ideas what could be the cause of the issue.
Does anyone have any idea what is going on?
edit: It may be useful to put emphasis on the fact that each script runs function calling 3 other functions -> all of them end with Range.setValues({values}). And for one sheet all of them work, and for the other - none.
That's the reason I assume there is something wrong with the sheet itself, maybe some permissions/protection? But I couldn't find anything :(
edit2: I modified my code to iterate through the sheet 10 rows at a time, because I thought maybe when I get a whole column, something bad happens with data and breaks setValues() function.
Unfortunately - even if my code iterated 1 row at a time, it still did not work on sheet1, but worked on sheet2. So not a data problem.
The code you show always puts values in yearAndWeekRange which is always in the 'sheet1' sheet. To put the data into another sheet, you need to change the target range appropriately.
The dimensions of the range must match the dimensions of the array you put there. Use this pattern:
yearAndWeekRange.offset(0, 0, arrData.length, arrData[0].length).setValues(arrData);
I found out what is the problem.
Two scripts were pretty identical, even with naming of variables - ie ackColumn, resColumn etc.
Those were stored as a global variables, so even if I was running script1.gs, it used global variables from script2.gs, effectively writing proper data to wrong sheet.
separating global variables names fixed the issue.
Perhaps a rookie mistake, but I missed the fact, that if I have a variable defined outside any function, it becomes global and could be overwritten from other file

Using named ranged in google sheets as variables in custom formulas

Not sure if this is going to make sense but here I go.
What I want to do is to create a formula that isn't linked to a cell directly. In example: if I want to calculate carryweight for a tabletop game like D&D I would need the formula (strengthBonus x 5). For my current attempt I renamed the range (cell rather) strengthBonus to MOD_STR so when I put the formula =(multiply(MOD_STR,5) it works like a charm. Then I named that range "CARRYWEIGHT" and then use it elsewhere.
What I would like to be able to do is to make a new variable, similar to the way that "Define Named Range" does, but instead of relying on the variables being somewhere on the spreadsheet they would process from an internal formula. For example, if I type =carryweight into a cell it would run the equation =MULTIPLY(MOD_STR,5) in that cell and output the answer. I know nothing about code yet but have just been pointed in the direction of tutorials but I'm also asking for help here.
The code I have tried is
function CARRYWEIGHT(MOD_STR){
return MOD_STR*2}
and something else, I can't remember what but I got it to at least accept it in the spreadsheet. When I type it in I get an error stating that the outcome isn't a number.
I have no idea where to go from here.
Thank you in advanced for your help.
The difference between sheets formulas and Apps Script is that in Apps Script you need to retrieve the value of the range corresponding to the name of a named range
You cannot simply multiply the name of the range (which is a string!) with a number
Here is a sample of how to retrieve a range by name and make calculations wiht the value stored in it:
function CARRYWEIGHT(MOD_STR){
// retrieve all named ranges in the spreadsheet
var namedRanges = SpreadsheetApp.getActive().getNamedRanges();
//loop through all the results
for (var i = 0; i < namedRanges.length; i++){
var range = namedRanges[i];
//if the range with the name equal to the value of MOD_STR is found, get the cell content of this range
if(range.getName()==MOD_STR){
var value = range.getRange().getValue();
// perform the calculation with the cell content of the named range
return value*2;
}
}
}
From the cell, call the function as =CARRYWEIGHT("paste here the name of the range of interest"), do not forget the quotes (unless it is a cell reference)!
I hope this helped you to get started, for further understanding plese consult the following references.
References
Named Ranges
Loops
Conditional statements
Ranges
getValue()

Google Sheets - Prevent users from pasting over cells with data validation

Is it possible to prevent a user from pasting in cells that have drop-down options based on data validation?
While it could be a convenience, when one cell (with data validation) is copied and pasted to another cell (with its own validation) it rewrites the data validation to match range being pasted, if that makes sense.
Alternatively, perhaps there is a script that will accept the info being pasted but rewrite that data validation to its original range??
This may be very tricky to pull off depending on your workbook usage, and is more complex than it sounds. In the old days, GAS had ScriptDB so developers would revert this kind of thing by recreating the "UNDO" functionality. When that was sunset, one recommendation was to have a duplicate sheet, and making sure that it always stays aligned with the user's active sheet - then, when the user messes up your data validations, you can just scan all data validation cells and revert those from the duplicate sheet (https://developers.google.com/apps-script/reference/spreadsheet/data-validation-builder). In any case, I concluded that Google Sheets was not made for this specific type of client-facing solutions (this is where Google Forms is used), so if you cannot make it work using "Protected Sheets and Ranges" you will probably end up implementing a hack, as follows.
Here was my hack approach; I was unable to prevent this from happening, however, I was able to auto-revert cells to proper format by using onEdit(), and reverting all damaged named cells after each user edit. The idea is to define rules inside names of named ranges.
(1) You can create a named range for every cell that you want to protect. The name of the named range must encapsulate your data validation rules. For example, if you have a dropdown cell at [B28 on a sheet with ID "1380712296"], that feeds from [range A11-A14 (4 items in the dropdown) on sheet with ID "936278947"], you can name the dropdown cell as follows:
z_1380712296_r?936278947?A11.A14_B28
Or, in a generic form:
z_ DROPDOWN-SHEET-ID _ DATA-VALIDATION-TYPE ? DATA-VALIDATION-SOURCE-SHEET-ID ? DATA-VALIDATION-START-CELL . DATA-VALIDATION-END-CELL _ DROPDOWN-CELL-RANGE-IN-A1-FORMAT
(2) Write a trigger to execute on every user edit, as follows:
function onEdit(e) {
try {
autocorrectFormat(e.range);
}
catch (e) {}
}
function autocorrectFormat(modifiedRange) {
// Get named ranges on active sheet
var sheetNamedRanges = SpreadsheetApp.getActiveSheet().getNamedRanges();
// Fix active cells that intersect
for (var i in sheetNamedRanges) {
// we only go through the named ranges that begin with z_ since others may be declared by users
if (sheetNamedRanges[i].getName().substring(0,2) == "z_") {
// This rangesIntersect function below is necessary because onEdit's e.range is often wrong when pasting groups of cells
// so we want to check if the edited range intersects with a named range's range rather than equals exactly
if (rangesIntersect(sheetNamedRanges[i].getRange(), modifiedRange)) {
// Here parse the information on the named range's name and fix the potentially damaged cell using the data
// validation rules pulled from the named range's name
var currentCellInformation = [sheetNamedRanges[i].getRange(),sheetNamedRanges[i].getName().split("_")];
// Parsing
var part_1 = currentCellInformation[1][2].charAt(0);
var part_2 = currentCellInformation[1][2].split("?");
// Function to rebuild the dropdown cell
fixRange(...);
}
}
}
return;
}
// https://stackoverflow.com/a/36365775/7053599
function rangesIntersect(R1, R2) {
return (R1.getLastRow() >= R2.getRow()) && (R2.getLastRow() >= R1.getRow()) && (R1.getLastColumn() >= R2.getColumn()) && (R2.getLastColumn() >= R1.getColumn());
}
As you noticed, pasting overwrites the former data validation rules. You may also want to assess the effects of Ctrl+X, Drag+Drop, and Ctrl+\ and their effects on named ranges, since, for example, Ctrl+X moves the named range as well, and Ctrl+\ never executes onEdit().
I included the code for you to get familiar with the kinds functions you would be using - you may also be using DataValidationBuilder in your fixRange() function where you rebuild the dropdown cell.
In response to "Alternatively, perhaps there is a script that will accept the info being pasted but rewrite that data validation to its original range??":
This is a good idea, it had occurred to me as well, but it is not possible to intercept information being pasted with GAS. The closest we have is onEdit(). This is why I mentioned above that I could not prevent it, but rather attempt to auto-revert damaged cells.

How do I keep values in 2 different sheets in sync?

I have a sheet that has some dates on it, like "25/02/2016", listed down a column. On the cells to the right of each date, there are some numeric values.
I need to copy these numeric values to a specific range on a different sheet. Ideally, it would work like a one-way sync, where I would write values on sheet1 and sheet2 would automatically be updated.
I've been looking through the Google Apps Script documentation, but I have no idea where to start. I do have some pseudo-code, just don't how to use it here.
function getDates() {
for (count = 0; count < sheet1.length; count++) {
if (hasDate) {
return(cell);
}
}
}
var numericValuesRows = sheet1.getDates().getRow();
var numericValuesRange = numericValuesRows.selectColumns(C-F);
Just making up method names and syntax. This bit is supposed to find out which rows have dates in them, and then select columns C to F in those rows. The next one is supposed to select the destination as all the cells from row3:columnC to row10:columnF, and copy the previously selected values to there.
var outputRange = sheet2.cellRange(C3-F10);
numericValuesRange.copyTo(outputRange);
I realise it's really crappy pseudo-code, but I'm hoping it at least helps in some way get across what I want to do. What would be the best way to do this?
Use the onEdit() trigger which will trigger when you edit a sheet. You can check the source of the edit to make sure its an edit to the cells you want in a particular sheet. Once that's done, its a matter of using getValue(), setValue() and getSheetByName(). Start here: simple triggers

Workaround for not losing cell's reference because of user doing sorting, while script checks cells value

I have been struggling with the following situation for almost the whole last week and it would be awesome if someone could give me some hint.
The situation:
1. Script finds a particular value in, lets say, 'Sheet1', and gets the row number of the cell containing this value.
2. Since script has found this value, it executes a bunch of actions like creating new spreadsheet and copying numerous 'Sheet1' from dozen other spreadsheets to this newly created spreadsheet, and comparing/ analyzing data.
The problem.
- While script is doing a bunch of other actions, user is able to rearrange cells by, for example, sorting A to Z, which changes the address of previously found cell.
Here is the code that I used in order to verify this:
function WhatHappensIfUserSorts () {
var ss = SpreadsheetApp.getActive();
var sh = ss.getActiveSheet();
var rng = sh.getRange("B17"); //Lets say that script finds this cell according to some rules
Utilities.sleep(10000);
rng.setValue("Test Value");
}
Question:
Might there be any workaround for this?
My current ideas.
1. I was thinking about hiding the filter row in the beginning of the script, but this doesn't help a lot, because users can insert new row in the which will change the addresses of the rows below.
The background.
I am trying to create two way synchronization, meaning, each project member has his/ her own spreadsheet with 'Project X', 'Project Y' etc. sheets and no matter who updates their project sheets, all other users that work on the same project get these updates in their project sheets. These updates that have to be tracked are not just the cell values, these are cell notes as well. And this is the reason why script has to do the bunch of other actions, since CopyTo method does not work between spreadsheets.
During my research I found sheetSpider project, but it seems somewhat different and too complicated from what I need.
A simple suggestion would be to give each row a unique identifier so that you could use it to evaluate the target range again before you write back to the sheet.
get target row's unique ID --> do work --> locate target rows ID and use to determine write range --> write back to sheet.
Alternatively, during the operation you could delete the target row and then use appendRow() to drop the updated version back in.
A third and final suggestion might be to temporarily suspend the permissions for the sheet. See: https://developers.google.com/apps-script/reference/spreadsheet/page-protection#setProtected