Adding a delay to a function - google-apps-script

I currently am able to successfully write the code for locking a single cell after editing (and only owner can edit after) correctly. But I want that function to execute after a delay of around 10-15 minutes (in case the editor changes their mind). So far this is what I have... the lockrow function works fine but I am unable to get the time trigger to work.
This is what I currently have:
function setUpTrigger() {
ScriptApp.newTrigger('lockRow')
.timeBased()
.after(600000)
.create() ;
}
function lockRow(e){
const sh = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
let protection = e.range.protect();
protection.removeEditors(protection.getEditors());
if (protection.canDomainEdit())
protection.setDomainEdit(false);
}
At Work, one thing I am tasked with is managing our on-call sign up sheet and need to lock the cells after someone signs up for a shift. This would make my job a lot less tedious.
I by no means would consider myself a "coder". I've gotten this far through trial and error but I have been stuck on this for 2 almost 3 weeks.
Any help would be much appreciated.
EDIT:
I asked a YouTuber for help and this is what he explained to me:
The 'e' value won't transfer through properly like this. You should
adjust your script so you declare and instanciate the needed values
(in this case just the range) in the setUpTrigger() function then pass
that value to the active function. So:
const r = e.range;
lockRow(r){
...
let protection = r.protect();
...
}
I'm honestly not even sure what he means.

Your lockRow function doesn't work when it's called by a time driven trigger because the time driven event object, in this case assigned to e, hasn't a range property, so it's very likely that you are getting a error on the following line:
let protection = e.range.protect();
You need to rethink your solution to define a way to set the range to be protected. One way to do this is by using the Properties Service, i.e. you could store a property having a list of all the ranges references (like A1) to be protected.
Please bear in mind that the property service can only store string values.
Example:
Here are a simple functions to set and get the range reference to be protected.
/**
* Run this function before creating the trigger, i.e. it might be called by the setUpTrigger function
*/
function setRangeToProtect(){
const properties = PropertiesService.getScriptProperties();
properties.setProperty('range','A1');
}
function getRangeToProtect(){
const properties = PropertiesService.getScriptProperties();
return properties.getProperty('range');
}
Then in the lockRow function replace
let protection = e.range.protect();
by
const range = sh.getRange(getRangeToProtect());
let protection = range.protect();
Depending on how your spreadsheet will be used, you will have to adapt the setRangeToProtect and getRangeToProtect and the way that they will be used / called.

Related

I need help creating Install trigger to replace onEdit()

I have created a spreadsheet that receives form input, fiddles about with the data and then I want to view one row of data by selecting a record based on an id field.
I read and watch a lot of vids and found this brilliant video that showed me how to write a script for it.
I made a simplified spreadsheet SimplifiedDB2023
and a script
//DummyDatabse2023
//const ss = SpreadsheetApp.openById("11Fw0OmFpvkkfh11c95gBd7qEWifGJO6eUv38UwKRxNU")
//SimplifiedDB2023
//const ss = SpreadsheetApp.openById("1c-y_XUuK0L86qUUX471-GdBT-CN5GNTk4EGK7G57_94")
const ss = SpreadsheetApp.getActiveSpreadsheet()
const updateWS = ss.getSheetByName ("Form")
const mergeddataWS = ss.getSheetByName ("mergeddata")
const fieldRange = ["P5","G7","C24","C25","D7","C27","J7","I9","J9","H9","G9","F9","D9","D11","G11","G12","C38","E14","C40","C41","C42","C43","H5","R7","R12","P10","C48","C49","C50","C51","C52","C53","C54","C55","C56","P6","P12","P14","P16","P8","R8","D5"]
const searchCell = updateWS.getRange("E2")
function search() {
const searchValue = searchCell.getValue()
const data = mergeddataWS.getRange("A2:AQ").getValues()
const recordsFound = data.filter(r => r[42] == searchValue)
if(recordsFound.length === 0) return
fieldRange.forEach((f,i) => updateWS.getRange(f).setValue(recordsFound[0][i]))
}
which worlked beautifully. When I edit cell E2. (Basically choosing from a list), using the onEdit function,
SimplifiedScript2023
it selects all the information from that row using a filter in the search() function..
When I followed the exact same procedure for my more complicated and involved spreadsheet,
DUMMYDB2023
I ran into issue. The problem seems to be that the second script I wrote
( which I basically copied and pasted ) doesn't seem to be "bound" to the sheet, so I cannot use : const ss = SpreadsheetApp.getActiveSpreadsheet().
as I get an error :
TypeError: Cannot read properties of null (reading 'getSheetByName')
I looked into this and then when I changed
const ss = SpreadsheetApp.openById("11Fw0OmFpvkkfh11c95gBd7qEWifGJO6eUv38UwKRxNU")
It worked, but I am not able to use the onEdit function, which is vital, because I want any edits to cell E2, to trigger the function (search).
So my questions are - Why does the second version (- DummyDB2023) not work - I suspect this is because this more developed sheet has links to external sources like the forms, but even so, I am the owner of the spreadsheet and I followed the exact same procedure to create the script so it 'should' be bound, but apparently is not.
The second question is, if I must use the openById, I need to create an installable trigger. I thought this would be quite simple, but I cannot find any comprehensible resource that shows how to create an installable trigger for when a single cell is edited.
Can anyone help me write a script for a trigger that runs my search function when cell E2, on sheet Form! is edited?
As always, thanks for your time and help.
I created a stripped down version of my spreadsheet and the procedure worked perfectly.
When I applied it to my full spreadsheet, it wouldn'T run.

How to tell a script which row to start output

I am using a script to output the date and time that a row was last updated on a Google Sheet. It seems to work just fine, but I want it to only output beginning at the second row, since my first row is a header row full of labels for the columns. I can't seem to figure it out.
The script is from here: https://www.wikihow.com/Google-Sheets-How-to-Insert-Time-in-Cell-Automatically#Script-Editor
This is how it looks in my Apps Script:
/** #OnlyCurrentDoc */
function onEdit(e){
const sh = e.source.getActiveSheet();
sh.getRange ('A' + e.range.rowStart)
.setValue (new Date())
.setNumberFormat ('MM/dd/yyyy HH:MMam/pm');
}
I think I need to define rowStart, but I'm not sure how to do that and search engines haven't pulled up answers I understand.
I tried appending rowStart(2) and rowStart > 0 in place of the original rowStart, which produced errors and made the whole script stop working. I also tried the below with the same response.
rowStart(
value : 2
) : 2;
From here: https://help.grapecity.com/spread/SpreadJSWeb/JavascriptLibrary~GcSpread.Sheets.PrintInfo~rowStart.html
I am new to Google Apps Script (and any scripting), though a longtime Excel and Sheets user. So please explain it to me like I am five. 😅 Branching out!
OK, so this script, as it says on the original wikihow page, will:
insert a timestamp into the specified column any time you enter data into a cell, in the same row as the data you entered. For instance, if you type something into cell A2, a timestamp will appear in cell M2.
So essentially the way that script works, at least per my reading:
It is defining an onEdit handler which handles an edit event every time a user makes any change to any cell in the spreadsheet
the edit event (called e in the script) has a bunch of information about the edit on it, including the row number of the first row that was edited (e.range.rowStart)
the script contains a hard-coded column letter (in your case, A, in the example on wikihow they used M)
every time any edit is made , it goes to the cell identified by the hardcoded column letter and the 1st row that was edited, and it inserts the current date into that cell.
First of all, since this is JavaScript, you can paste your code into a text editor that supports JavaScript, like VSCode for example, and ask it to format it for you. This might bring some clarity to the code because the text editor knows what the syntax means, so it can give you some hints about whats going on or at very least it can make it look nicer:
/** #OnlyCurrentDoc */
function onEdit(e) {
const sh = e.source.getActiveSheet();
sh.getRange('A' + e.range.rowStart)
.setValue(new Date())
.setNumberFormat('MM/dd/yyyy HH:MMam/pm');
}
Second of all, single letter variable names and abbreviations are often frowned upon because they can prevent us from knowing what's going on. And sometimes code can be clarified by giving names to things that might otherwise not be obvious at all. So I will do some naming in this code:
/** #OnlyCurrentDoc */
function onEdit(editEvent) {
const sheet = editEvent.source.getActiveSheet();
const rowThatWasEdited = editEvent.range.rowStart;
const columnThatHoldsTheLastEditedDates = 'A';
const correspondingCell = columnThatHoldsTheLastEditedDates + rowThatWasEdited;
const rightNowDate = new Date();
sheet.getRange(correspondingCell)
.setValue(rightNowDate)
.setNumberFormat('MM/dd/yyyy HH:MMam/pm');
}
Finally, since this is javascript, we have access to all of the javascript features like if statements, loops, functions, etc. Since you want to make it ignore the header row, I would recommend an if statement: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/if...else
if we are on the header row, do nothing. Otherwise, do the normal thing.
/** #OnlyCurrentDoc */
function onEdit(editEvent) {
const sheet = editEvent.source.getActiveSheet();
const rowThatWasEdited = editEvent.range.rowStart;
if(rowThatWasEdited > 1) {
const columnThatHoldsTheLastEditedDates = 'A';
const correspondingCell = columnThatHoldsTheLastEditedDates + rowThatWasEdited;
const rightNowDate = new Date();
sheet.getRange(correspondingCell)
.setValue(rightNowDate)
.setNumberFormat('MM/dd/yyyy HH:MMam/pm');
}
}

Can I duplicate a trigger with my new spreadsheet?

I have a new client template that I want to keep from being filled out prior to being duplicated. I've set a trigger to run a duplication and renaming function for the file when the file is opened.
function newRecord(){
var sheet = SpreadsheetApp.getActiveSpreadsheet();
var scheck = sheet.getName();
if (scheck=='#New Client Record'){
var file = DriveApp.getFilesByName('#New Client Record').next();
file.setName('New Client')
file.makeCopy('#New Client Record');
}
}
This checks to see that the file is the correct file before proceeding, changes the name of the template and then duplicates the file and renames it the old template name. I configured it in this way so that when we start filling out the data right away while we're talking on the phone with a client, we aren't sullying our original template which remains pristine. It works nicely except that the trigger from file #1 doesn't transfer to file #2 which takes on the new identity of the original template.
So my question is - can I duplicate the trigger as well? Or is there script that can open the duplicate file and close the template file to protect my form?
EDIT: I threw a bit more at this last night and tried to add an install trigger with limited effect (I probably sound like a boomer talking about smoking the drugs with this - I'm not a programmer and have only a rudimentary vocabulary for script). I added the following function, which is transferring, but does not seem consistent in its functionality (excusing the pun).
function createSpreadsheetOpenTrigger() {
var ss = SpreadsheetApp.getActive();
ScriptApp.newTrigger('newRecord')
.forSpreadsheet(ss)
.onOpen()
.create();
}
Thanks for any assistance any of you can provide on this. I've stumbled my way this far on my own with the archives, but I've finally gotten myself stuck.
How to get a form to create a new sheet with values from a template
Here is a quick example of how to carry this out. What you need to see this example in action:
A template sheet that looks like this:
A form that will have 3 "Short answers" that are required.
Client Name
Phone
Email
A standalone script (Drive Web app > New > More > Google Apps Script).
A folder into which all the newly created sheets will go.
At the top of the script (outside any function) you can define 3 global variables with the appropriate IDs:
const FORM_ID = ...
const TEMPLATE_ID = ...
const CLIENT_FOLDER_ID = ...
First, to set up an onFormSubmit trigger, you can do this by running the following function only once.
function createTrigger(){
const form = FormApp.openById(FORM_ID)
ScriptApp.newTrigger("formSubmitReceiver")
.forForm(form)
.onFormSubmit()
.create()
}
After which comes the main function that will:
Receive the formSubmit event and create a range of values from it.
In this example, it will generate a followUpDate that will be 7 days from the submission of the form.
Create a copy of the template file.
Fill the range B1:B4 with the relevant info.
Resulting in a new spreadsheet:
function formSubmitReceiver(e){
const itemResponses = e.response.getItemResponses()
const values = itemResponses.map( itemResponse => [itemResponse.getResponse()] )
const followUpDate = new Date()
followUpDate.setDate(followUpDate.getDate() + 7)
values.push([followUpDate])
const newFile = DriveApp.getFileById(TEMPLATE_ID).makeCopy()
const parentFolder = DriveApp.getFolderById(CLIENT_FOLDER_ID)
newFile.moveTo(parentFolder)
newFile.setName(values[0][0])
const newId = newFile.getId()
const newSpreadsheet = SpreadsheetApp.openById(newId)
const sheet = newSpreadsheet.getSheetByName("Sheet1");
const range = sheet.getRange("B1:B4")
range.setValues(values)
}
Depending on how complex you make your form, and what type of questions you choose (i.e multiple choice etc) this can get more complicated but hopefully it will give you a good idea of how to get something like this working. The simplest way is just to use "Short Answers" as this will just return a string. Also remember to make the questions "Required" if you don't want to handle empty values. Again, this all depends on how exactly you want to implement it and the complexity of the information involved!
References and Further Reading
FormResponse
getItemResponses()
ItemResponse
getFileById(id)
makeCopy()

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.

Google sheets. Lock range

I have 2 lists with ranges C2:F21, which should be available to fill for all users in a certain period.
Range C2:F21 in the list A shouldn't be locked from 01.01.2020 to 05.01.2020, but in another time it should be locked for all users except me.
In the list B range C2:F21 shouldn't be locked only from 01.02.2020 to 05.02.2020.
I will be very grateful for any answer
The most "secure" way to handle this would be to actually save the data as a separate spreadsheet (only you have permission to edit) on the expiration date(s) which you could also automate with a script. You could then use a series of importrange function(s) to create a list of read only information if you need the users to see it in the master sheet. Anyone who is an editor of the spreadsheet has the ability (perhaps not the knowledge) to get around your cell protection in various creative ways.
If you still insist on using cell protection to accomplish this it is very simple, use the Protection class and add a trigger to the project using the little clock in the script editor, Select event source Time-driven and time based trigger Specific date and time.
First you would need to protect the ranges, something that you can do easily through the web UI by selecting the range you want to protect, right-clicking and selecting Protect range. You can do it too via Apps Script, by running a function like this one:
function protectRangeA() {
var ss = SpreadsheetApp.openById("your-spreadsheet-id"); // Change accordingly
var sheetA = ss.getSheetByName("your-sheet-name"); // Change accordingly
var protection = sheetA.getRange("C2:F21").protect();
var me = Session.getEffectiveUser();
protection.addEditor(me);
protection.removeEditors(protection.getEditors());
if (protection.canDomainEdit()) {
protection.setDomainEdit(false);
}
}
This function gets a sheet with a certain name from a spreadsheet with a certain id (as others said before me, for security reasons - to avoid people messing with your script - it would be better if it was a standalone script, not bound to your spreadsheet), and protects the range C2:F21, using protect(), and you will be the only editor.
Second, you want to unprotect this range at a certain date, an protect it again at another date. To do that, you could create time-driven triggers. You could, for example, use an atDate trigger, that will run the function you specify at the year, month and day you specify. So, if you want to unprotect a range at 01.01.2020, you could, for example, have this function:
function createUnprotectTriggerA() {
ScriptApp.newTrigger("unprotectRangeA")
.timeBased()
.atDate(2020, 1, 1)
.create();
}
This will fire a function called unprotectRangeA near midnight of 01.01.2020 (+/- 15 minutes). The function that will be run at this time, then, should unprotect the desired range. It could be something on the following lines:
function unprotectRangeA() {
var ss = SpreadsheetApp.openById("your-spreadsheet-id"); // Change accordingly
var sheetA = ss.getSheetByName("your-sheet-name"); // Change accordingly
var protections = sheetA.getProtections(SpreadsheetApp.ProtectionType.RANGE);
for (var i = 0; i < protections.length; i++) {
var protection = protections[i];
if (protection.canEdit()) {
protection.remove();
}
}
}
This function uses getProtections to get all range protections that exist in the sheet and, before checking if the current user can edit them via canEdit(), it removes these with the use of remove().
And to protect it again at 05.01.2020, have this trigger, which will fire the function protectRangeA (previously defined) at this date:
function createProtectTriggerA() {
ScriptApp.newTrigger("protectRangeA")
.timeBased()
.atDate(2020, 1, 5)
.create();
}
Finally, because you want to protect/unprotect several ranges and you would need several triggers, you could call all the functions that create triggers in the same function, which you would have to run once to set all the triggers you need (I didn't define createProtectTriggerB and createUnprotectTriggerB but it would be the same as with the other range):
function createTriggers() {
createProtectTriggerA();
createUnprotectTriggerA();
createProtectTriggerB();
createUnprotectTriggerB();
}
Please beware that, as CodeCamper said, if users have permission to edit the spreadsheet, they could potentially use a script to mess up with your protections. So, depending on the people who are editing this, maybe you should have a copy of the spreadsheet that only you can access.
I hope this is of any help.