Google Apps Script to VMerge tables WITH FORMATTING - google-apps-script

Does anyone know if there is a Google apps script out there that does what VMerge does but keeps the formatting of the tables being merged together? (in Google Spreadsheets)
VMerge is a script that can be used as a custom formula but a script that I can trigger myself will do just fine too.
Any help would be much appreciated.

VMerge expects arrays-of-values as parameters, and therefore does not know what cells were referenced creating those arrays. When used as a Custom Formula, the sheet parser resolves all range parameters into their values before passing them to VMerge. Additionally, the parameters may be hard-coded or be the result of Queries or other functions that return ranges. Because of this alone, it's not feasible to modify VMerge to copy cell formats to the new merged table.
Complicating things further, Custom Functions cannot modify cells outside of the one they are attached to, they can only return values or arrays of values. From comment in Issue 37:
2) Scripts used as cell functions are not allowed to do complex things
like connect to other APIs or set the values of other cells. Scripts
used as cell functions are only allowed to return a value.
So you're going to have to settle for a function you call from scripts. The following function will join multiple ranges into a new table at a given anchor point. Because I started out trying to make this a custom function callable from a sheet, the parameters are string expressions of ranges, in a1Notation. (It could easily be refactored to deal directly with Range objects.)
The "Anchor" for the new range is expected to be a cell. One or more ranges of any size may be joined - each will be positioned directly below the previous.
Examples:
VJoin("D1","A1:B"); - All of columns A & B duplicated in columns D & E
VJoin("Sheet2!A1","Sheet1!C9:E10","Sheet1!A14:B15"); - Two different ranges in Sheet 1 joined and copied to Sheet 2.
Here's the code:
/*
* Vertically join the ranges from multiple sources into a new table
* starting at the given anchor point. Values and formatting are copied.
*
* #param {a1Notation} anchorA1 Anchor for joined table.
* #param {a1Notation} sources One or more source ranges.
*/
function VJoin(anchorA1,sources) {
var sheet = SpreadsheetApp.getActiveSheet();
var anchor = sheet.getRange(anchorA1);
var anchorSheet = anchor.getSheet(); // in case anchorA1 is not on the "active sheet"
var nextAnchor = anchor;
for (var i in arguments) {
// Arguments are expected to be Strings, containing a1Notation.
if (i == 0) continue; // First argument was anchorA1, skip it.
if (arguments[i].constructor == String) {
var source = sheet.getRange(arguments[i]);
var destination = anchorSheet.getRange(nextAnchor.getRow(), nextAnchor.getColumn(),
source.getNumRows(), source.getNumColumns() );
// Copy all values & formatting to new location.
source.copyTo(destination);
// Prepare for next range by moving our anchor
nextAnchor = sheet.getRange(nextAnchor.getRow() + source.getNumRows(),
nextAnchor.getColumn());
}
else {
throw new Error ("Expected String containing a1Notation.")
}
}
}

If you need a separate script to bring over the formatting...
function myFunction() {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var s = ss.getSheetByName('Sheet1');
s.getRange('A1').copyFormatToRange(sheet, column, columnEnd, row, rowEnd);
}

I find the below built in functions to work well pulling information from different Google Sheet files. I have defined named ranges to define what columns to pull into the Master, and also know I am having an issue with Feb.
=sort(arrayformula({
importrange("1sTS3AUfoXqXYrMYJrro9pGEKwqVL_k854yhniNOHNWc","JCJan");
importrange("1ETSD4J-8AI-7pVK0hXJKaWtG3RlHKpnco88Yj8sqNN8","JCFeb")}),1,True)

Related

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.

App Script Conditional Formatting to apply on sheet by name

I have been trying to make a Google App Script code which highlight the cell if it has specific text like "L".
I have made a below code but its not working and when i run this no error appears. i do not know what is the problem.
Can you have a look at it, please that why its not working.
function formatting() {
var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Dec');
var range = sheet.getRange("C:AG");
if (range == 'L') {
ss.range.setBackgroundColor('#ea9999');
}
}
Issues with the code:
Three things to mention:
range is a range object, not a string. In the if condition you are comparing an object of type range with an object of type string. You need to use getValue to get the values of the range object and then compare that with the string L.
This code will take a lot of time to complete because you have a large range of cells you want to check but also you are iteratively using GAS API methods. As explained in Best Practices it is way more efficient to use batch operations like getValues,
getBackgrounds and setBackgrounds.
Another improvement you can make is to use getLastRow to restrict the row limit of your range since you are looking for non-empty values. There is no reason for checking empty cells after the last row with content.
Google Apps Script Solution:
function formatting() {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Dec');
const range = sheet.getRange("C1:AG"+sheet.getLastRow());
const values = range.getValues();
const bcolors = range.getBackgrounds();
const new_bcolors = values.map((r,i)=>r.map((c,j)=>c=='L'?'#ea9999':bcolors[i][j]))
range.setBackgrounds(new_bcolors)
}
Google Sheets Solution:
Another idea would be to just create a conditional formatting in Google Sheets:
and specify a custom color with your hex code:
JavaScript References:
map
ternary operator

Sheets Filter Script across multiple tabs displaying blank rows on master sheet, and also causing other scripts to stop functioning

I have a multifaceted question.
I'm attempting to have a script which pulls the tab names, and uses that info to pull specific cells from each one (tabs for the doc change frequently) in order to create a Master Sheet. The Master Sheet is meant to display all open action items, and filter out any closed items / blank rows. The script I have so far works, but it pulls all empty rows from each tab - which I don't want.
Here's 1 of the 2 current scripts I have for the master sheet:
function onEdit(e) {
//set variable
const masterSheet = "Open Action Items";
const mastersheetFormulaCell = "E2";
const ignoreSheets = ["Template", "Blank"];
const dataRange = "I2:K";
const checkRange = "J2:J"
//end set variables
const ss = SpreadsheetApp.getActiveSpreadsheet();
ignoreSheets.push(masterSheet);
const allSheets = ss.getSheets();
const filteredListOfSheets = allSheets.filter(s => ignoreSheets.indexOf(s.getSheetName()) == -1);
let formulaArray = filteredListOfSheets.map(s => `iferror(FILTER('${s.getSheetName()}'!${dataRange}, NOT(ISBLANK('${s.getSheetName()}'!${checkRange}))),{"","",""})`);
let formulaText = "={" + formulaArray.join(";") + "}";
ss.getSheetByName(masterSheet).getRange(mastersheetFormulaCell).setFormula(formulaText);
}
The other part of this is another script that has been running ok when it was the only script running, but has since stopped working when the other script were introduced. This script added a checkbox to column C based on criteria in column B.
Here's that script:
function onEdit(e) {
if (e.range.getColumn() == 2) {
var sheet = e.source.getActiveSheet();
if (e.value === "Tech Note" ||
e.value === "Intake Process")
sheet.getRange(e.range.getRow(),3).insertCheckboxes();
else
sheet.getRange(e.range.getRow(),3).removeCheckboxes();
}
}
Here's a sample sheet
The "Open Action Items" tab is the master sheet the script is meant to update. It should list all the open items from other tabs (explained below)
The "Copy of E3-O Case Notes" is a tab which is the basis of what every tab will eventually look like. Columns F-K of this tab pull open items from A-E. There may likely be a more efficient way to sort this whole sheet...
Any help appreciated, thank you!
I'll address the second question first, as it's a more fundamental problem.
The other part of this is another script that has been running ok when it was the only script running, but has since stopped working when the other script were introduced.
In the script project attached to your sample, you have 3 files which each define an onEdit() function. This is problematic because each time you define onEdit() you're redefining the same identifier. The project only has a single global scope, so there can only be 1 onEdit() function defined, regardless of how many files your project contains.
Essentially, this is equivalent to what you've defined in your project:
function onEdit(e) {
console.log("onEdit #1");
}
function onEdit(e) {
console.log("onEdit #2");
}
function onEdit(e) {
console.log("onEdit #3");
}
onEdit();
Running the above snippet will only execute the last definition of onEdit().
To accomplish what you're trying to do, you can instead define unique functions for all the actions you want to perform and then, in a single onEdit() definition, you can call those functions. Something like:
function editAction1(e) {
console.log("edit action #1");
}
function editAction2(e) {
console.log("edit action #2");
}
function editAction3(e) {
console.log("edit action #3");
}
function onEdit(e) {
editAction1(e);
editAction2(e);
editAction3(e);
}
onEdit();
When defining an onEdit() trigger, you really want to optimize it so that it can complete its execution as quickly as possible. From the Apps Script best practices, you want to pay particular attention to "Minimize calls to other services" and "Use batch operations".
A few specific tips for you:
Avoid repeated calls to the same Apps Script API (e.g. Sheet.getName()). Instead, run it once and store the value in local variable.
As much as possible, avoid making Apps Script API calls within loops and in callback functions passed to methods such as Array.prototype.filter() and Array.prototype.map().
When you do need to loop through data, especially when Apps Script API calls are involved, minimize the number of times you iterate through the data.
With onEdit() triggers, try to structure the logic so that you identify cases where you can exit early (similar to how you perform the column check before going ahead with manipulating checkboxes). I doubt you actually need to iterate through all of the sheets and update the "Open Action Items" formula for every single edit. If I'm interpreting the formula properly, it's something that should only be done when sheets are added or removed.
Finally, to address the blank rows in your formula output, instead of using SORT() to group the blank rows you can use QUERY() to actually filter them out.
Something like:
=QUERY({ <...array contents...> }, "select * where Col1 is not null")
Note that when using QUERY() you need to be careful that the input data is consistent in regards to type. From the documentation (emphasis mine):
In case of mixed data types in a single column, the majority data type
determines the data type of the column for query purposes. Minority
data types are considered null values.
In your sample sheet, a lot of the example data varies and doesn't match what you'd actually expect to see (e.g. "dghdgh" as a value in a column meant for dates). This is important given the warning above... when you have mixed data types for a given column (i.e. numbers and strings) whichever type is least prevalent will silently be considered null.
After taking a closer, end-to-end look at your sample, I noticed you're performing a very convoluted series of transformations (e.g. in the data sheets there's the hidden "D" column, the QUERY() columns to the right of the actual data, etc.). This all culminates in a large set of parallel QUERY() calls that you're generating via your onEdit() implementation.
This can all be made so much simpler. Here's a pass at simplifying the Apps Script code, which is dependent on also cleaning up the spreadsheet that it's attached to.
function onEdit(e) {
/*
Both onEdit actions are specific to a subset of the sheets. This
regular expression is passed to both functions to facilitate only
dealing with the desired sheets.
*/
const validSheetPattern = /^E[0-9]+/;
updateCheckboxes(e, validSheetPattern);
updateActionItems(e, validSheetPattern);
}
function updateCheckboxes(e, validSheetPattern) {
const sheet = e.range.getSheet();
// Return immediately if the checkbox manipulation is unnecessary.
if (!validSheetPattern.exec(sheet.getName())) return;
if (e.range.getColumn() != 2) return;
const needsCheckbox = ["Tech Note", "Intake Process"];
const checkboxCell = sheet.getRange(e.range.getRow(), 3);
if (needsCheckbox.includes(e.value)) {
checkboxCell.insertCheckboxes();
} else {
checkboxCell.removeCheckboxes();
}
}
function updateActionItems(e, validSheetPattern) {
const masterSheetName = "Open Action Items";
const dataLocation = "A3:E";
/*
Track the data you need for generating formauls in an array
of objects. Adding new formulas should be as simple as adding
another object here, as opposed to duplicating the logic
below with a growing set of manually indexed variable names
(e.g. cell1/cell2/cell3, range1/range2/range3, etc.).
*/
const formulas = [
{
location: "A3",
code: "Tech Note",
},
{
location: "E3",
code: "Intake Process",
},
];
const masterSheet = e.source.getSheetByName(masterSheetName);
const sheets = e.source.getSheets();
/*
Instead of building an array of QUERY() calls, build an array of data ranges that
can be used in a single QUERY() call.
*/
let dataRangeParts = [];
for (const sheet of sheets) {
// Only call getSheetName() once, instead of multiple times throughout the loop.
const name = sheet.getSheetName();
// Skip this iteration of the loop if we're not dealing with a data sheet.
if (!validSheetPattern.exec(name)) continue;
dataRangeParts.push(`'${name}'!${dataLocation}`);
}
const dataRange = dataRangeParts.join(";");
for (const formula of formulas) {
/*
And instead of doing a bunch of intermediate transformations within the sheet,
just query the data directly in this generated query.
*/
const query = `SELECT Col5,Col1,Col4 WHERE Col2='${formula.code}' AND Col3=FALSE`;
const formulaText = `IFERROR(QUERY({${dataRange}},"${query}"),{"","",""})`;
formula.cell = masterSheet.getRange(formula.location);
formula.cell.setFormula(formulaText);
}
}
Here's a modified sample spreadsheet that you can reference.
The one concession I made is that the data sheets still have a "Site Code" column, which is automatically populated via a formula. Having all the data in the range(s) you feed into QUERY() makes the overall formulas for the "Open Action Items" sheet much simpler.

Changing info on a different sheet in the same spreadsheet

I have two ranges of equal size on different sheets in the same spreadsheet. I am trying to find a row (based off of user input) in the first sheet and then use that index to modify a table in the second sheet that counts how many times that certain index has been used before (to make a nice looking pie chart).
This code runs but will not produce results on the second sheet. I've gone through the debugging process and my best guess is that for some reason, my for in loop is not running through. Attached is my code that takes in the beforementioned index and attempts to perform the second half of my goal.
function acceptToEncounterChart(ghostrow) {
var ss = SpreadsheetApp.getActiveSpreadsheet();
SpreadsheetApp.setActiveSheet(ss.getSheets()[1]);
ss = SpreadsheetApp.getActiveSpreadsheet();
var sheet = ss.getSheetByName("Average Encounter Chart");
var range = sheet.getRange("B3:B14")
for(var i in range) {
if(ghostrow == i) {
var before = range[i][0].getValue()
range[i][0].setValue(before + 1);
}
}
SpreadsheetApp.setActiveSheet(ss.getSheets()[0]);
};
Explanation:
I am not entirely sure what is your goal.
However, here is some fixes / improvements starting from the beginning:
You define 2 times the same variable ss with exactly the same value.
You don't need to set the active sheet, if your goal is to just get the sheet, therefore this line is redundant:
SpreadsheetApp.setActiveSheet(ss.getSheets()[1]);
Variable range is not an array but a range object. You can't index it and therefore you can't also use a for loop to iterate over a single object. For the same exact reason, the code inside the if statement is wrong, you can't index range. But you don't see any errors because the if statement evaluates to false.
In JavaScript and in many other programming languages, array indexes start from 0. Since your range starts from cell B3 or row 3, you need to use i+3 to match the data with the range.
For the same reason as the previous point, ghostrow is an index, not a row. The if statement compares an array index i with ghostrow, so ghostrow should not be confused with the actual sheet row. For example, if you choose ghostrow=5 then the current script will increment the value of the cell B8 (remember i+3) by 1.
Solution:
Here is a workable code snippet:
function acceptToEncounterChart(ghostrow) {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sheet = ss.getSheetByName("Average Encounter Chart");
var data = sheet.getRange("B3:B14").getValues().flat();
data.forEach((v,i)=>{
if(ghostrow == i){
sheet.getRange(i+3,2).setValue(v+1)
}
});
ss.setActiveSheet(ss.getSheets()[0]);
}
Related:
Please explore the official google apps script documentation.

Clear Invalid Values from Spreadsheet

I'm using Google Spreadsheets for this:
I have a spreadsheet which is basically a 4-week planner. Each day is divided into several slots, which can be assigned to any of our active clients. These cells have validation rules which reject invalid values.
The data that is permitted by the validation rules is sourced from a list on a separate sheet, which filters out clients when their status is changed from 'Active' to 'Cancelled', meaning they can no longer be assigned. The status is changed manually. Once an assigned client changes to 'Cancelled', it becomes an invalid client on the calendar.
Is there a way, using scripts, to find and clear the values of cells containing these invalid values? I've included a screen clipping below. The red corner is the invalid value.
I already have the onEdit trigger set up to run code, this will be calling a function to deal with this specific area.
screen clipping
Any help will be appreciated.
The code would look something like this:
function onEdit(e) {
//First check if you want the entire code to execute
if (myNeededCondtion !=== "theValueToMach") {
//End the code here
return;
}
var mySpreadsheet = SpreadsheetApp.getActiveSpreadsheet();
var theSheet = mySpreadsheet.getSheetByName("name of sheet");
var arrayOfColumnValues = theSheet.getRange(row to start at, column to start at, numRows, numColumns).getValues();
var i=0;
var thisValue = "";
for (i=0;i<arrayOfColumnValues.length;i+=1) {
thisValue = arrayOfColumnValues[i][0];
if (thisValue==="Cancelled") {
//Set the cell value to a blank string
theSheet.getRange(i, column).setValue("");
};
};
};
You need to figure out what the range value parameters need to be, and edit the code. Add the correct sheet name to the getSheetByName method. Note that getValues() returns a two dimensional array. Each inner array represents a row. If you only get one column of data, then each inner array will only have one element in it. Arrays are indexed starting at zero.