App script not depending on column position - google-apps-script

I want to be able to insert columns in my data or rearrange column positions without affecting the appscript functions.
Instead of referring to column positions by number, I thought of referring to them by header values (I will not change the names).
I made a small function which will make it easy for writing the code.
The output of the function will be stored in the sheet and then I will copy paste it to my app script function.
Is my approach all right or is there a better way to do it?
function get_headers(){
var ss=SpreadsheetApp.getActiveSpreadsheet();
var sheets=ss.getSheets();
var fin=[];
var tsheet=ss.getSheetByName("Product");
var shtname=tsheet.getName();
var [hd_Product, ...data_Product] = tsheet.getDataRange().getDisplayValues();
for (j=0;j<hd_Product.length;j++) { fin.push("const "+shtname+"_"+hd_Product[j]+"=hd_product.indexOf('"+hd_Product[j]+ "');") };
Browser.msgBox(fin);
//tsheet.getRange(tsheet.getLastRow(), 1, fin.length, fin[0].length).setValues(fin);
The below code - for running it for all the shets is not woeking.
for (i=0;i<sheets.length;i++){
var shtname=sheets[i].getName();
var tsheet=ss.getSheetByName(shtname);
var [header, ...data] = tsheet.getDataRange().getDisplayValues();
for (j=0;j<header.length;j++) {
fin.push("const "+shtname+"_"+header[j]+"=header.indexOf('"+header[j]+ "');") ;
}//for j
Browser.msgBox(fin);
}//for i
//Browser.msgBox(fin);
}
//
//
function process(){
const Product_Code=hd_product.indexOf('Code');
const Product_Item=hd_product.indexOf('Item');
const Product_UOM=hd_product.indexOf('UOM');
const Product_CAT=hd_product.indexOf('CAT');
const Product_Price=hd_product.indexOf('Price');
const Product_Min_Stock=hd_product.indexOf('Min_Stock');
const Product_Lot=hd_product.indexOf('Lot');
const Product_Now_Stock=hd_product.indexOf('Now_Stock');
const Product_Upd_Date=hd_product.indexOf('Upd_Date');
const Product_Vendor1=hd_product.indexOf('Vendor1');
const Product_Vendor2=hd_product.indexOf('Vendor2');
const Product_Vendor3=hd_product.indexOf('Vendor3');
}

A better way is to associate DeveloperMetaData with that column:
const setup = () => {
const sh = SpreadsheetApp.getActive().getSheets()[0];
const setupHeaders = new Map([
['Code', 'A:A'],// associate 'Code' with range A:A/Col 1
['Item', 'B:B'],
]);
setupHeaders.forEach((range, header) =>
sh.getRange(range).addDeveloperMetadata('secretTag', header)
);
};
Once setup, the column can be moved anywhere and the associated metadata will also move. You can then retrieve the current column position using DeveloperMetaDataFinder, even if you change the headers name in the sheet:
const getDevMeta_ = () =>
new Map(
SpreadsheetApp.getActive()
.createDeveloperMetadataFinder()
.withKey('secretTag')
.withLocationType(SpreadsheetApp.DeveloperMetadataLocationType.COLUMN)
.find()
.map(md => [md.getValue(), md.getLocation().getColumn()])
);
const getColumnsForHeaders = (headers = ['Item', 'Code']) => {
const map = getDevMeta_();
return headers.map(header => map.get(header));
};
const test1 = () => console.log(getColumnsForHeaders(['Code', 'Item'])); //returns current location of code and item
References:
Developer metadata guide

function myFunction() {
const ss=SpreadsheetApp.getActive();
const sh=ss.getActiveSheet();
const rg=sh.getDataRange();
const vA=rg.getValues()
//Just learn how to use the next 3 or 4 lines and it's pretty easy you can integrate them into any code
const hA=vA[0];//this is the header row with a [0] index at the end to flatten it out to a single dimension array.
const col={};//returns column from header name
const idx={};//returns index from header name
hA.forEach(function(e,i){col[e]=i+1;idx[e]=i;});//this is the loop to fill in the objects above depending upon whether you want indexes or columns
//Example of how to use them
vA.forEach(function(r,i){}) {
r[idx[headername]];//referring to an index with column header
sh.getRange(i+1,col[headername]).setValue();//reffering to a column with header name
}

How to get a column number by header value
An easy way to return the column number would be the following
function myFunction() {
var searchedHeader = "Hello World";
var column = returnColumn(searchedHeader);
}
function returnColumn(header) {
var ss=SpreadsheetApp.getActiveSpreadsheet();
var tsheet=ss.getSheetByName("Product");
var headers = tsheet.getRange(1, 1, 1, tsheet.getLastColumn()).getValues();
var column = (headers[0].indexOf(header) + 1);
if (column != 0) {
return column;
}
}
You can use the dynamic variable column storing the column number to perform all kind of requests, e.g.
var range = tsheet.getRange(5, column);

Related

Function Keeps Looping After Last Row

I am using the following code to fill a Google Docs template with data pulled from a spreadsheet.
function createBulkMembershipCards() {
const template = DriveApp.getFileById("--------");
const docFolder = DriveApp.getFolderById("----------");
const pdfFolder = DriveApp.getFolderById("----------------");
const libroSoci = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("LibroSoci");
const data = libroSoci.getRange(352, 1, libroSoci.getLastRow()-1,19).getDisplayValues();
data.forEach(row => {
createMembershipCard (row[3],row[4],row[0],row[1], row[6],template,docFolder,pdfFolder);
});
}
function createMembershipCard (name,surname,msnumber,timestamp, email,template,docFolder,pdfFolder) {
const file = template.makeCopy(docFolder);
const docFile = DocumentApp.openById(file.getId());
const body = docFile.getBody();
body.replaceText("{name}", name);
body.replaceText("{surname}", surname);
body.replaceText("{msnumber}", msnumber);
body.replaceText("{timestamp}", timestamp);
body.replaceText("{email}", "<<"+email+">>");
docFile.saveAndClose();
docFile.setName(msnumber+" "+name+" "+surname);
const pdfBlob = docFile.getAs(MimeType.PDF);
pdfFolder.createFile(pdfBlob).setName(msnumber+" "+name+" "+surname);
}
I do not understand why, even though I used "getLastRow", the function keeps going on indefinitely after the last populated row.
Please not that I put 352 as the starting row because I want to extract data from that row on.
Is anyone able to help?
Thank you!
Let's say getLastRow() returns 1000. Then you are getting 999 rows. What you want is 1000-351 rows.
Change
const data = libroSoci.getRange(352, 1, libroSoci.getLastRow()-1,19).getDisplayValues();
To
const data = libroSoci.getRange(352, 1, libroSoci.getLastRow()-351,19).getDisplayValues();

finding duplicates that are not actual exact duplicates and may have a few characters different

In a previous that was resolved for me at Use Conditional formatting for whole Google Sheet Workbook to search for duplicates
I have found that it doesn't actually search for exact duplicates or duplicates where only the last three letters may be different.
I was trying to find a way where the it searches for the first 10 characters and if the first 10 characters are the same or the last 10 characters are the same, then it highlights, however, if there is a single difference, it does not highlight it at all.
The following script is written by SO user Iamblicus in this post. If anyone can change this code
const EXCLUDED_SHEETS = ["Sheets that won't be updated"];
function updateAllSheets() {
const ss = SpreadsheetApp.getActive();
const sheets = ss.getSheets().filter(sheet => !EXCLUDED_SHEETS.includes(sheet.getName()));
const [cFormula, dFormula] = ["C","D"].map(column => sheets.reduce((acc, sheet, i) => {
const sheetName = sheet.getSheetName();
return acc + (i!=0 ? "+" : "") + `Countif(INDIRECT("'${sheetName}'!${column}:${column}"),left(${column}1,18)&\"*\")`;
}, `=if(${column}1<>\"\",`) + "> 1)");
sheets.forEach(sheet => {
const cRange = sheet.getRange("C1:C" + sheet.getLastRow());
const cRule = SpreadsheetApp.newConditionalFormatRule()
.whenFormulaSatisfied(cFormula)
.setBackground("yellow")
.setRanges([cRange])
.build();
const dRange = sheet.getRange("D1:D" + sheet.getLastRow());
const dRule = SpreadsheetApp.newConditionalFormatRule()
.whenFormulaSatisfied(dFormula)
.setBackground("yellow")
.setRanges([dRange])
.build();
sheet.setConditionalFormatRules([cRule,dRule]);
});
}
so that it is search for exact finds, that would be great.
Also I was hoping for an extra line where I can it will exclude specific worksheets. I am happy to be add the name of the specific worksheets in the code itself, as this can change from time to to time.
I don't know if it's possible to do with conditional formatting. Probably it would be more efficient if you're changing the cells constantly.
Just in case, here is the static pure script solution:
const EXCLUDED_SHEETS = ['Sheets that won\'t be updated'];
const HEAD_LENGTH = 10;
const TAIL_LENGTH = 10;
const COLOR = 'yellow';
function main() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sheets = ss.getSheets().filter(sh => !EXCLUDED_SHEETS.includes(sh.getName()));
sheets.forEach(sh => colorize_cells(sh, 'C'));
sheets.forEach(sh => colorize_cells(sh, 'D'));
}
function colorize_cells(sheet, column) {
const range = sheet.getRange(column + '1:' + column + sheet.getLastRow());
range.setBackground(undefined);
const backgrounds = range.getBackgrounds();
const rows = get_rows_to_colorize(range.getValues().flat());
rows.forEach(row => backgrounds[row][0] = COLOR);
range.setBackgrounds(backgrounds);
}
function get_rows_to_colorize(arr) {
const obj = {};
// make the object {'head1/tail1':[row indexes], 'head2/tail2':[row indexes], ...}
arr.forEach((elem, i) => {
let el = elem.toString();
let head = el.slice(0, HEAD_LENGTH);
let tail = el.slice(el.length - TAIL_LENGTH);
if (head.length == HEAD_LENGTH && tail.length == TAIL_LENGTH) {
if (head) if (head in obj) obj[head].push(i); else obj[head] = [i];
if (tail) if (tail in obj) obj[tail].push(i); else obj[tail] = [i];
}
});
// remove from the object the 'heads' and 'tales' that have only one row
for (let key in obj) {
// remove duplicates from the array [1,1,2] --> [1,2]
obj[key] = [...new Set(obj[key])];
// remove the keys which value is an array that has just one element
// {head1:[1,2],head2:[3]} --> {head1:[1,2]}
if (obj[key].length == 1) delete obj[key];
}
// return row indexes
return Object.values(obj).flat();
}
It colorizes cells on all the sheets (except excluded) within columns ('C' and 'D' in this case) if 'heads' or 'tails' of the cell values aren't unique within the column.
You have to re-run the main function every time you change values to get correct highlights.

How to apply multiple formatting to characters in one cell with apps script

I have many cells with multiple questions and answers in one cell like A1. When I run the apps script, I want the blue text formatting to be applied only to the answer line, like B1.
The algorithm I was thinking of is as follows.
Cut the questions and answers based on the newline character and make a list and condition processing within loop.
If the first character is -, it is a question, so pass or apply black format.
If the first character is ┗, it is an answer, so apply blue formatting.
But I'm new to apps script and google sheet api, so I don't know which way to go. Could you please write an example?
Try on active cell for instance
function formatCell() {
const range = SpreadsheetApp.getActiveRange()
// const range = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet().getDataRange() // for the whole sheet
const spec = { regex: /┗.*/gi, textColor: 'blue' };
const values = range.getDisplayValues();
let match;
const formattedText = values.map(row => row.map(value => {
const richText = SpreadsheetApp.newRichTextValue().setText(value);
const format = SpreadsheetApp.newTextStyle()
.setForegroundColor(spec.textColor)
.build();
while (match = spec.regex.exec(value)) {
richText.setTextStyle(match.index, match.index + match[0].length, format);
}
return richText.build();
}));
range.setRichTextValues(formattedText);
}
reference
Class RichTextValueBuilder
Use RichTextValue to set stylized text, and apply the corresponding TextStyle in lines that start with your desired character:
function changeAnswersColor() {
const sheet = SpreadsheetApp.getActiveSheet();
const textStyle = SpreadsheetApp.newTextStyle().setForegroundColor("blue").build();
const range = sheet.getRange("A1");
const values = range.getValues();
const richTextValues = values.map(row => {
return row.map(value => {
const valueLines = value.split("\n");
let builder = SpreadsheetApp.newRichTextValue().setText(value);
for (let i = 0; i < valueLines.length; i++) {
const vl = valueLines[i];
if (vl.trim()[0] === "┗") {
console.log(valueLines.slice(0,i).join("\n"))
const startOffset = valueLines.slice(0,i).join("\n").length;
const endOffset = startOffset + vl.length + 1;
builder.setTextStyle(startOffset, endOffset, textStyle);
}
}
const richTextValue = builder.build();
return richTextValue;
});
});
range.offset(0,range.getNumColumns()).setRichTextValues(richTextValues);
}

Randomize rows when google doc is opened

I am trying to create a petition with a google form and doc. I've found an app script that will take the responses from the form and input them into a table on the doc. To help reduce the bias early respondents may face, I am trying to develop a script that will randomize the rows of the table every time the document is opened. Trying to scramble the signatures so that the same signatures aren't always at the top (can't be targeted).
Can anyone help me with this?
Below is the code I am using to populate the table from the form
//Since there could be a bunch of people submitting, we lock the script with each execution
//with a 30 second timeout so nothing gets overwritten
const lock = LockService.getScriptLock();
lock.waitLock(30000);
//Here we read the variables from the form submission event
const date = new Date(e.values[0]).toLocaleDateString();
//of you can use toLocaleString method if you want the time in the doc
const name = e.values[1];
const employeeNumber = e.values[2];
var form = FormApp.openById('');
const num = form.getResponses().length;
var num1 = num.toString()
//Next format those values as an array that corresponds to the table row layout
//in your Google Doc
const tableCells = [num1,name,employeeNumber]
//Next we open the letter and get its body
const letter = DocumentApp.openById('')
const body = letter.getBody();
//Next we get the first table in the doc and append an empty table row
const table = body.getTables()[0]
const tableRow = table.appendTableRow()
//Here we loop through our table cells from above and add
// a table cell to the table row for each piece of data
tableCells.forEach(function(cell, index){
let appendedCell = tableRow.appendTableCell(cell)
})
//here we save and close our letter and then release a lock
letter.saveAndClose();
lock.releaseLock();
}
Got this from a helpful vimeo tutorial by Abhishek Narula and Rebekah Modrak.
Randomize row in a google document table
First function Creates a table in a google document from a table in a spreadsheet
function creatTable() {
const style1 = {};
style1[DocumentApp.Attribute.HORIZONTAL_ALIGNMENT]=DocumentApp.HorizontalAlignment.RIGHT;
const ss = SpreadsheetApp.openById("ssid");
const sh = ss.getSheetByName('Sheet0');
const tA = sh.getDataRange().getValues();
const doc = DocumentApp.getActiveDocument();
let body = doc.getBody();
body.clear();
let n = body.getNumChildren();
for(let i =0;i<n-1;i++) {
if(i==0) {
body.getChild(i).asText().setText('');
} else {
body.getChild(i).removeFromParent()
}
}
let table=body.appendTable(tA).setAttributes(style1);
}
Second function reads the table from the document and randomizes the rows it and reloads the table. This function actually searches through all of the children to find a table. If there is more than one table it will randomize all of them so it will need to be modified to integrate with your specific document.
This now removes the first line and then randomizes the array and then adds the first line back
function readandrandomizetable() {
const style1 = {};
style1[DocumentApp.Attribute.HORIZONTAL_ALIGNMENT] = DocumentApp.HorizontalAlignment.RIGHT;
const doc = DocumentApp.getActiveDocument();
let body = doc.getBody();
let vs = [];
let fl = '';
[...Array.from(new Array(body.getNumChildren()).keys())].forEach(idx => {
let ch = body.getChild(idx);
if (ch.getType() == DocumentApp.ElementType.TABLE) {
Logger.log(`This is the index I want: ${idx}`);
let tbl = ch.asTable();
[...Array.from(new Array(tbl.getNumRows()).keys())].forEach(ridx => {
let s = tbl.getRow(ridx).getText().split('\n');
//Logger.log(JSON.stringify(s));
vs.push(s);
});
Logger.log(JSON.stringify(vs));
fl = vs.shift();
vs.sort((a, b) => {
vA = Math.floor(Math.random() * 100);
vB = Math.floor(Math.random() * 100);
return vA - vB;
});
vs.unshift(fl);
}
});
Logger.log(JSON.stringify(vs));
body.clear();
let n = body.getNumChildren();
for (let i = 0; i < n - 1; i++) {
if (i == 0) {
body.getChild(i).asText().setText('');
} else {
body.getChild(i).removeFromParent()
}
}
body.appendTable(vs).setAttributes(style1);
}

Sorting background colors in a range of cells using custom comparator function

I use a script to sort a sheet on a particular item in a column.
Data in this column is a number preceded by letters so the normal sort method from the range or sheet class does not work properly.
Here is the script, the actual question comes below.
function sortIDs(){ // test on column 5
sortOnNumbersInCol(5);
}
function sortOnNumbersInCol(col){ // numeric sort except for 2 first rows on numeric value in column col
var sh = SpreadsheetApp.getActive().getSheetByName('copie de travail');
var data = sh.getDataRange().getValues();
//Logger.log('length départ='+data.length);
var codes = data.shift();
var headers = data.shift();
var dataOut=[];
for(var n=0 ; n<data.length ; n++){
if(data[n][col] != ''){
dataOut.push(data[n]);
//Logger.log('data['+n+']['+col+']='+data[n][col].substring(7)+'|');
}
}
dataOut.sort(function(a,b){
var aE,bE;
aE=a[col].substring(7); // the numeric part comes at the 7 th position (and following)
bE=b[col].substring(7);
return aE - bE
})
dataOut.unshift(headers);
dataOut.unshift(codes);
sh.getRange(1,1,dataOut.length,dataOut[0].length).setValues(dataOut);
}
This works perfectly for data sorting but it doesn't care about cell colors obviously...
Some of my coworkers use colors to designate items in this sheet and when I sort the range the colors don't follow.
So my question is : How can I sort this sheet with my specific criteria and keep the correlation with cell colors ?
below is a screen capture of sheet data
If I run my script on this range the colors won't move... that's my problem ;)
Update:
Another way to do this without the space complexity is by sorting the keys/indexes instead of the actual array.
function sortOnNumbersInCol(col = 5) {
const sh = SpreadsheetApp.getActive().getSheetByName('copie de travail');
const dataRg = sh.getDataRange();
const noOfHeadrs = 2;
const rg = dataRg.offset(
//Remove headers before getting values
noOfHeadrs,
0,
dataRg.getNumRows() - noOfHeadrs,
dataRg.getNumColumns()
);
let data = rg.getValues();
let colors = rg.getBackgrounds();
/* Filter Empty rows if needed
[data, colors] = [data, colors].map(arr =>
arr.filter((_, i) => data[i][col] !== '')
);*/
const dataKeys = [...data.keys()];
dataKeys.sort(function(a, b) {
return data[a][col].substring(7) - data[b][col].substring(7);
});
const [dataOut, colorsOut] = [data, colors].map(arr =>
dataKeys.map(i => arr[i])
);
const outRg = sh.getRange(
noOfHeadrs + 1,
1,
dataOut.length,
dataOut[0].length
);
outRg.setValues(dataOut);
outRg.setBackgrounds(colorsOut);
}
Create a map of {data:colors}:
const rg = sh.getDataRange();
const data = rg.getValues();
const colors = rg.getBackgrounds();
const dcMap = data.reduce((mp, row, i) => mp.set(row[col], colors[i]),new Map)
Then after sorting, you could use the sorted values as key and create a new sorted color array:
dataOut.sort(function(a,b){
var aE,bE;
aE=a[col].substring(7); // the numeric part comes at the 7 th position (and following)
bE=b[col].substring(7);
return aE - bE
})
dataOut.unshift(headers);
dataOut.unshift(codes);
const sortedColors = dataOut.map(row=>dcMap.get(row[col]));
const outRg = sh.getRange(1,1,dataOut.length,dataOut[0].length);
outRg.setValues(dataOut);
outRg.setBackgrounds(sortedColors);
}
Use the sort method from Class Filter as this besides including the cells background color will include other cell properties like notes, comments, data-validation and conditional formatting.
As the OP already mentioned this method can't be used directly as the column to be sorted has composed values (string + consecutive numbers without leading zeros). In order to do this we will need to add an auxiliary column which in this case is relatively easy as the range to be sorted is got by using getDataRange().
NOTES:
The following code use an arrow function so it requires to use the new runtime.
function myFunction() {
var sheet = SpreadsheetApp.getActiveSheet();
// Add the auxiliary column
var lastRow = sheet.getLastRow();
var codes = sheet.getRange(1,2,lastRow).getValues();
var newCodes = codes.map(([value]) => [value.match(/\d{1,}/)]);
var newColumn = sheet.getRange(1,sheet.getLastColumn()+1,lastRow).setValues(newCodes);
SpreadsheetApp.flush();
// Sort
var range = sheet.getDataRange();
var values = range.getValues();
var filter = range.createFilter();
filter.sort(values[0].length, true);
// Remove the filter and the auxiliary column
filter.remove()
sheet.deleteColumn(values[0].length)
}
Demostration
Before
After
If for any reason you decide to use the old runtime, replace
var newCodes = codes.map(([value]) => [value.match(/\d{1,}/)]);
by
var newCodes = codes.map(function([value]){ return [value.match(/\d{1,}/)]});
If you want to keep things ES5, simply use an object as a map and sort colors and values independently of each other.
/**
*
* #param {(string|number)[]} data
* #param {string[]} colors
* #returns {(string|number)[][][]}
*/
function sortOnNumbersInCol(data, colors, col) {
//var codes = data.shift();
//var headers = data.shift();
var map = {};
for (var n = 0; n < data.length; n++) {
if (data[n][col] != '') {
map[colors[n][col]] = data[n][col];
}
}
var dataOut = data.sort(function(a, b) {
var aE = a[col].substring(7);
var bE = b[col].substring(7);
return aE - bE;
});
var colorsOut = colors.sort(function(a, b) {
var aC = map[a[col]].substring(7);
var bC = map[b[col]].substring(7);
return aC - bC;
});
//dataOut.unshift(headers);
//dataOut.unshift(codes);
return [
dataOut,
colorsOut
];
}
//testing is done with ES6 syntax
const createGrid = (parentTable, valGrid, colorGrid) => {
const {
firstElementChild
} = parentTable;
valGrid.forEach((r,rowIdx) => {
const row = document.createElement("tr");
r.forEach((c,cellIdx) => {
const cell = document.createElement("td");
cell.textContent = c;
cell.style.backgroundColor = colorGrid[rowIdx][cellIdx];
firstElementChild.append(cell);
});
firstElementChild.append(row);
});
};
var col = 2;
var values = [
[5, 6, "MCINPRO13"],
[1, 2, "MCINPRO2"],
[7, 8, "MCINPRO24"],
[3, 4, "MCINPRO9"]
];
var colors = [
[0, 0, "#D8D9DA"],
[0, 0, "#007030"],
[0, 0, "#6D99B4"],
[0, 0, "#FF6347"]
];
const before = document.querySelector("#before");
createGrid(before, values, colors);
sortOnNumbersInCol(values, colors, col);
const after = document.querySelector("#after");
createGrid(after, values, colors);
<table id="before">
<tbody>
</tbody>
</table>
<hr>
<table id="after">
<tbody>
</tbody>
</table>
Note that the snippet below has header shift > unshift flow
disabled for ease of testing and setValues removed to make the
snippet runnable.
For simplicity of the snippet, sort is also performed directly on the data array, so you will have to map over the initial data to keep things pure: data = data.map(function (v) { return v; }).