Copy value of date field in YouTrack - javascript

I want to copy the value from issues in one project to issues in another that depend on it.
That is what I have:
var entities = require('#jetbrains/youtrack-scripting-api/entities');
var workflow = require('#jetbrains/youtrack-scripting-api/workflow');
exports.rule = entities.Issue.onChange({
// TODO: give the rule a human-readable title
title: 'Date-propagation',
guard: function(ctx) {
var links = ctx.issue.links['depends on'];
return ctx.issue.isChanged("Date") || !links.added.isEmpty() || !links.removed.isEmpty();
},
action: function(ctx) {
var issue = ctx.issue;
var links = issue.links['depends on'];
function updateIssue(normalIssue){
normalIssue.fields.DueDate = issue.fields.Date.value;
}
function checkList(list){
if(list.isNotEmpty())list.forEach(function(normalIssue){updateIssue(normalIssue);}) ;
}
//checkList(links.removed);
checkList(links);
// TODO: specify what to do when a change is applied to an issue
},
requirements: {
Date: {
type: entities.Field.dateType,
},
Depend: {
type: entities.IssueLinkPrototype,
outward: 'is required for',
inward: "depends on"
}
}
});
The problem is in this line:
normalIssue.fields.DueDate = issue.fields.Date;
How should it be done?

Most probably, you do not have a 'DueDate' field on your instance (as the default field is called 'Due Date'). If so, your code line should look like this:
normalIssue.fields['Due Date'] = issue.fields.Date;

Related

How to disable modifying workitems in YouTrack workflows?

I want to prevent users in YouTrack from modifying or adding workitems in the past. They should only add/modify workitems in current day.
In YouTrack workflows i can detect changed Spent time event and prevent users from adding workitem. But I want to get an event when user is modifying workitem in JavaScript workflows. Here is my code:
var entities = require('#jetbrains/youtrack-scripting-api/entities');
var workflow = require('#jetbrains/youtrack-scripting-api/workflow');
exports.rule = entities.Issue.onChange({
title: workflow.i18n('Disable editing workitems'),
guard: function(ctx) {
return ctx.issue.fields.isChanged(ctx.ST);
},
action: function(ctx) {
workflow.check(ctx.issue.workItems.added.isEmpty(), workflow.i18n('You can add/modify workitems only in current day.'));
},
requirements: {
ST: {
type: entities.Field.periodType,
name: 'Spent Time'
}
}
});
Datetime conditions are omitted...
Check for ctx.issue.workItems.isChanged and also check the content of ctx.issue.editedWorkItems set.
Not sure if its still relevant, but I have full example:
/**
* This is a template for an on-change rule. This rule defines what
* happens when a change is applied to an issue.
*
* For details, read the Quick Start Guide:
* https://www.jetbrains.com/help/youtrack/server/2022.2/Quick-Start-Guide-Workflows-JS.html
*/
const entities = require('#jetbrains/youtrack-scripting-api/entities');
const workflow = require('#jetbrains/youtrack-scripting-api/workflow');
exports.rule = entities.Issue.onChange({
title: 'Prevent-workitem-updates-past',
guard: (ctx) => {
const issue = ctx.issue;
const fs = issue.fields;
// NOT using issue.workItems.added.isEmpty() , because we also want to detect EDITS as well as ADDS to the WorkItems array.
return fs.isChanged(ctx.ST) && !issue.becomesReported;
},
action: (ctx) => {
const issue = ctx.issue;
function checkWorkItemDate(currentWorkItem, currentEpochTimeStamp){
workflow.check(currentWorkItem.date == currentEpochTimeStamp, workflow.i18n('You can add/modify workitems only in current day.'));
}
//const splittedDate = new Date().toISOString().slice(0, 10).split('-');
//const currentEpoch = new Date(splittedDate[0], splittedDate[1] - 1, splittedDate[2]).valueOf();
const currentDate = new Date();
const currentEpoch = new Date(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate()).valueOf();
// Check newly added elements
issue.workItems.added.forEach(workItem => {
checkWorkItemDate(workItem, currentEpoch);
});
// Check edited elements
issue.editedWorkItems.forEach(workItem => {
checkWorkItemDate(workItem, currentEpoch);
});
workflow.check(true);
},
requirements: {
ST: {
type: entities.Field.periodType,
name: 'Spent Time'
}
}
});

Update a field in all documents and embedded documents based on matching query MongoDB NodeJS

I have pretty complex query that I am trying to write and can't seem to wrap my head around the best way to write it. Ultimately I am trying to avoid having to write it all out manually. What I am trying to do is to take a dynamic field and go through each document to see if that field exists and if it does then update it. The issue is that the field can exist more than once in document since it can exist on an embedded level in multiple embedded documents per single parent document.
Here is what a typical object would look like:
applications: {
_id: 5368hss76sd9f,
ProgramAreaId: 1,
ProgramArea: 'Education',
StrategyId: 16,
Strategy: 'Graduate Program',
AlternateMapping: [{
ProgramAreaId: 2,
ProgramArea: 'Private Sector',
StrategyId: 13,
Strategy: 'Higher Education Fund'
}],
Funding: [{
Amount: '500000'
StrategyId: 16
Strategy: 'Graduate Program'
},
{
Amount: '500000'
StrategyId: 13
Strategy: 'Higher Education Fund'
}
]
}
I may have several thousand of these that I will need to update at a time. The ultimate goal would be to do it in one query. I have made it work for a single field at the base level but was wondering if there was a way to make it work for all of the fields that match the dynamic name even in embedded documents.
Here is what I have tried so far:
var fieldId = obj.Type + 'Id'; //obj.Type could equal 'Strategy' or 'ProgramArea'
var field = obj.Type;
var id = 13; //This could be any number of ids and ultimately was what I want to match on.
var qry = {
$where: function() {
var deepIterate = function(o, value) {
for (var field in o) {
if (field == fieldId && obj[field] == value) {
console.log()
return true;
}
var found = false;
if (typeof o[field] === 'object') {
found = deepIterate(o[field], value)
if (found) {
return true;
}
}
}
return false;
};
return deepIterate(this, id)
}
};
var setQry = { $set: {} };
setQry.$set[field] = obj.Name;//Field here would be 'Strategy' or 'ProgramArea' or any number of other fields I could be updateing and Name is what I want the value of that field to equal.
mongo.collection('applications').updateMany(qry, setQry, function(err, result) {
if (err)
callback(null);
else
callback(result);
});
The above query will find me any 'application' that contains the field name equal to the field name I am asking for and it will even search through embedded documents to see if that field is there. The issue with the above query is that it will only update that field on the parent level rather than updating the children as well.
So I think I have found the best solution to the above code. I created this following code to accomplish the above issue and it works wonderfully!
var resultsArray = [];
var fieldId = obj.Type + 'Id';
var field = obj.Type;
if (coll == 'statuses') {
fieldId = "StatusId";
field = "Status";
}
var altmapField = 'AltMaps.' + fieldId,
altfundField = 'AltFunds.' + fieldId,
paymentField = 'Payments.' + fieldId,
reportField = 'Reports.' + fieldId,
crosswalkField = 'Crosswalk.' + fieldId,
regionField = 'RegionBreakdowns.' + fieldId,
sectorField = 'SectorBreakdowns.' + fieldId;
var qry = [];
qry.push({
$match: {
$or: [{
}, {
}, {
}, {
}, {
}, {
}, {
}, {
}]
}
});
qry[0].$match.$or[0][fieldId] = id;
qry[0].$match.$or[1][altmapField] = id;
qry[0].$match.$or[2][altfundField] = id;
qry[0].$match.$or[3][paymentField] = id;
qry[0].$match.$or[4][reportField] = id;
qry[0].$match.$or[5][crosswalkField] = id;
qry[0].$match.$or[6][regionField] = id;
qry[0].$match.$or[7][sectorField] = id;
var cursor = mongo.collection(collectionPrefix + 'applications').aggregate(qry, { allowDiskUse: true, explain: false }, null);
Essentially all I did was build out a query dynamically and then pass that into mongo aggregation and it read it like a champ.

ExtJS Ajax problems for grid panel

I have a grid Panel in my code as:
Ext.create('Ext.grid.Panel', {
id : 'frPanel-' + interfaceId,
store : frStore,
columns : [
{
text : 'Sequence',
dataIndex : 'ruleId',
menuDisabled : true
},
{
text : 'Source',
dataIndex : 'source',
renderer : function(value, metaData) {
var newValue = convertObjValue(value);
if (newValue.match(/[-]+/i)) {
metaData.tdAttr = 'data-qtip="'
+ networkStore(value) + '"';
}
return newValue;
}
},
// paging bar at the bottom
dockedItems : [ {
xtype : 'pagingtoolbar',
store : frStore, // same store GridPanel is using
dock : 'bottom',
displayInfo : true
} ],
height : 300,
width : '100%',
forceFit : true,
renderTo : 'frContainer-' + interfaceId
});
And these are the helper function i have:
// To get the value after 2nd colon for object and object-group
function convertObjValue(value) {
var result;
var exp = /.*?:.*?:(.*)/i;
var newValue = value;
if ((result = exp.exec(value)) != null) {
if (result.index === exp.lastIndex) {
exp.lastIndex++;
}
newValue = result[1];
}
return newValue;
}
The store:
function networkStore(value) {
//var store = Ext.create('Ext.data.Store', {
var store = new Ext.data.Store({
model : 'networkModel',
autoLoad : {
timeout : 60000
},
proxy : {
type : 'ajax',
url : networkObjsURL + "&" + Ext.urlEncode({
'peId' : value
}),
reader : {
type : 'json',
idProperty : 'objValue'
},
}
});
var hoverOutput = "";
if(store.data.length > 0){
store.data.items.forEach(function(item) {
hoverOutput += item.data.objectValue + "</br>";
});
}
console.log(hoverOutput);
return hoverOutput;
and last but not the least is the model:
Ext.define('networkModel', {
extend : 'Ext.data.Model',
fields : [ {
name : 'objectValue'
} ]
});
Now comes the issue. The problem is when i dont place the breakpoint in the browser in store, the values wont show up in qtip. Im guessing thats because of the grid panel not waiting for the response back from the store after ajax response. Can someone help me figure out a workaround for this situation?
Thanks in advance
Have you tried setting
autoLoad:false
and then something like :
store.load({
callback: function(records, operation, success) {
if (success == true) {
//do your stuff
var hoverOutput = "";
if(store.data.length > 0){
store.data.items.forEach(function(item) {
hoverOutput += item.data.objectValue + "</br>";
});
}
console.log(hoverOutput);
return hoverOutput;
} else {
// the store didn't load, deal with it
}
}
// scope: this,
});
Since your breakpoint allows you to see your data, im thinking you are right in assuming it's a delay issue. Since Ext is asynchronous, it wont wait for the ajax call to be finished before continuing it's processing. A callback will help you manage this as it will be called when ajax returns.
I'm still also fairly new to Ext but at least that's my understanding. Hope it helps, or at least points you in the right way.
Edit because i reminded that sometimes having a return inside the success will make it hard to debug, etc. So you could also try changing your success to call another function and have that function do the processing, just keep the scope in mind.
I asked in comment which version ExtJS you're using but I didn't get response so I assume that you are using ExtJS 5.
Control Flow in your code is strange for me.
Why do you create store in render function indirectly (directly in networkStore) multiple times?
Nevertheless, store is fetching data asynchronous, so you have to wait/callback result (or use Future/Promise API for example). In addition, you should have any necessary data for the grid in frStore store (which you pass to the grid). You can also take advantage of data association in your model or you can create new field in model with convert function and use value of the association/field in render function.
Let me show you one of the approach how to do that (a simple one).
ExtJS doesn't like modifying records inside render function so we prepare a model which has a necessary qtip value.
I assume that you can load data of networkStore earlier (autoload: true) but this is for simplicity and you can change it later, for example using remoteFilter and callbacks.
You don't show definition of frStore and underlying model so I will use FrStore and FrModel as class names.
Ext.define('FrModel', {
extend: 'Ext.data.Model',
// ...
fields: [
// source field
// ...
/** qtip value **/
{
name: 'qtip',
type: 'string',
convert: function (value, record) {
var result = '';
// below code is from your render function with modifications
if (record.get('rendered_source').match(/[-]+/i)) {
result = 'data-qtip="'
+ networkStore(record.get('source')) + '"';
}
return result;
},
depends: ['source', 'rendered_source']
},
/** rendered source **/
{
name: 'rendered_source',
type: 'string',
convert: function (value, record) {
var newValue = convertObjValue(record.get('source'));
return newValue;
},
depends: ['source']
}
]
// ...
}
After that change render function is simple:
// ...
{
text : 'Source',
dataIndex : 'rendered_source', // this will allow users to sort & filter this field by the values which are displayed
renderer : function(value, metaData, record) {
metaData.tdAttr = 'data-qtip="'
+ record.get('qtip') + '"';
}
return value;
}
},
// ...
You can need also a NetworkStore which you could place in seperate file: (I prefer proxy/schema in model but I've used your code)
Ext.create('Ext.data.Store', { // using Ext.create is better
model : 'networkModel',
storeId: 'networkStore', // registering store in Ext.data.StoreManager in order to get later this store by Ext.getStore(<store_id>)
autoLoad : true,
proxy : {
type : 'ajax',
url : networkObjsURL, // we load all records but I mentioned earlier that you can change this
reader : {
type : 'json',
idProperty : 'objValue'
},
}
});
I added peId field in netowrkModel because we want to query store later.
Ext.define('networkModel', {
extend : 'Ext.data.Model',
fields : [
{
name: 'objectValue'
},
{
name: 'peId',
type: 'int'
}
]
});
The last part is the networkStore function:
function networkStore(value) {
var store = Ext.getStore('networkStore');
var records = store.query('peId', value, false, true, true);
var hoverOutput = "";
if (records.length > 0) {
records.each(function(item) {
hoverOutput += item.get('objectValue') + "</br>";
});
}
console.log(hoverOutput);
return hoverOutput;
}
PS. I do not test above code.
However, IMO correct solution uses associations. I recommend you read this doc.
You should get to know concepts like schema, proxy, association and others.
When you join FrModel with NetworkModel by peId then you won't need NetworkStore and you build qtip in convert function based on that association.

JQuery plugin not working on codepen, works great elsewhere

I have a plugin that I just wrote, which is so far acting just I had hoped while developing. I attempted to take this code out of my scripts directory and post it on Codepen.io. I get a script error when running it there, the error is cannot read 'Action' of undefined. It gets instantiated by a click event which passes an event. Both using jQuery 2.1. Anyone know whats happening here?
Heres the codepen:
http://codepen.io/nicholasabrams/pen/uJKrL
// $ DOC
$.fn.dataValidate = function(event, userSettings) {
"use strict";
event.preventDefault();
var api = {
// Script definition defaults defined in object below
notNull: {
errorText: 'This field is required',
symbol: false,
Action: function(dataToCheck, instance) {
if (dataToCheck === '' || dataToCheck === null || dataToCheck === 'undefined' || dataToCheck.length === 0 ) {
// if true return true to caller
alert('null');
// Retrieve errorText
// Wrap in error template
this.errorForNotNull = new api.ErrorInjector(instance);
return false;
}
else {
return true;
}
}
},
isNaN: {
errorText: 'Numbers not allowed here',
symbol: false,
Action: function(dataToCheck, instance) {
api.notNull.Action(dataToCheck, instance); /* Reuse the notNull method as a screening service before entering into the method specific filtering (assuming null fields would be inappropriate in any types of check) */
if (isNaN(dataToCheck)){ // Check if the not null field is also non a number
return true;
}
else {
this.errorForIsNan = new api.ErrorInjector(instance);
return false;
}
}
},
isNum: {
errorText: 'Please enter a number',
symbol: false,
Action: function(dataToCheck, instance) {
api.notNull.Action(dataToCheck, instance);
if (!isNaN(dataToCheck)){ // Check if the not null field is also non a number
return true;
}
else {
this.errorForIsNan = new api.ErrorInjector(instance);
return false;
}
}
},
isEmail: {
errorText: 'Please enter a valid email address',
symbol: false,
Action: function(dataToCheck, instance) {
api.notNull.Action(dataToCheck, instance);
var checkEmailRegEx = /^(([^<>()[\]\\.,;:\s#\"]+(\.[^<>()[\]\\.,;:\s#\"]+)*)|(\".+\"))#((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
if (checkEmailRegEx.test(dataToCheck)){
}
else {
this.errorForIsEmail = new api.ErrorInjector(instance);
}
}
},
isPw: {
errorText: 'Please enter a password',
symbol: false,
Action: function(dataToCheck, instance) {
api.notNull.Action(dataToCheck, instance);
console.log(dataToCheck);
if (dataToCheck.length > 4){
var isPwRegEx = /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])[0-9a-zA-Z]{8,}$/;
if(isPwRegEx.test(dataToCheck)){
// Multiple pw checkpoints here
// At least one upper case English letter
// At least one lower case English letter
// At least one digit
// At least one special character
return false;
}
else {
this.errorForIsPw = new api.ErrorInjector(instance);
return true;
}
}
} // End length check for isPw
},
isPhoneNumber: {
errorText: 'Please enter a valid phone number',
symbol: false,
Action: function(dataToCheck, instance) {
api.notNull.Action(dataToCheck, instance);
this.errorForIsPhoneNumber = new api.ErrorInjector(instance);
}
},
isUsername: {
errorText: 'Please enter a valid username',
symbol: false,
Action: function(dataToCheck, instance) {
api.notNull.Action(dataToCheck, instance);
var checkUsernameRegEx = /^[a-zA-Z0-9.\-_$#*!]{3,30}$/;
if (checkUsernameRegEx.test(dataToCheck)){
alert('valid username');
}
else {
this.errorForIsEmail = new api.ErrorInjector(instance);
}
}
},
isNamePart: {
errorText: 'Please enter a valid name',
symbol: false,
Action: function(dataToCheck, instance) {
api.notNull.Action(dataToCheck, instance);
var checkNamePartRegEx = /^[a-zA-Z ]+$/;
if (checkNamePartRegEx.test(dataToCheck)){
alert('valid name part');
}
else {
this.errorForIsEmail = new api.ErrorInjector(instance);
}
}
},
// New method would be added here
errorOutput: 'validated',
targets: ['[data-validate="notNull"]', '[data-validate="isNaN"]',
'[data-validate="isNum"]', '[data-validate="isEmail"]', '[data-validate="isPw"]', '[data-validate="isPhoneNumber"]', '[data-validate="isUsername"]','[data-validate="isNamePart"]'],
// Target selectors, can be modified on initialization to that of your liking, as well as have new ones added. Add a new selector target at the end of the array above
placeholder: {
// Template shared by each validation output error
template: {
defaultPlaceholderContainerStyle: 'position: relative;background:#ccc;',
defaultPlaceholderStyle: 'position: absolute;left:0;top:0;width:100%;line-height:26px;height:100%;',
// The above styles may be easily detached by simply tranfering the above CSS to a style rule matching the errorOutput class outlined above in this same object, or set on instantiation
},
},
ErrorInjector: function(instance) {
var errorNs = instance.data('validate');
var error = '<div data-validate="output" class="' + api.errorOutput + '">' + api[errorNs].errorText + '<\/div>';
instance.wrap('<div data-validate="output_container" class="' + api.errorOutput + '_container"><\/div>');
instance.before(error);
},
acceptedTypes : ['input[type="text"]','input[type="email"]','input[type="password"]','input[type="checkbox"]','input[type="radio"]','input[type="tel"]'],
results: {} // NS for all validation results and debugging info (see below)
};
// Merge the caller sent options object with the defaults. Any options set in on init from the caller will overwrite the default/internal settings
this._overrideApiWithUserSettings = (function() {
$.extend(true, api, userSettings);
})();
var targetsAll = api.targets;
// Private utility for removing the validationOutput errors from the DOM
this._removeThisErrorFocusThisInput = function() {
var activeOutputPlaceholder = $(this);
activeOutputPlaceholder.unwrap();
activeOutputPlaceholder.remove();
$.each(api.acceptedTypes, function(){
var eachTypeInAcceptedTypes = this;
activeOutputPlaceholder.find(eachTypeInAcceptedTypes).focus();
});
$('body').unbind('click', '.' + api.errorOutput);
};
$('body').on('click', '.' + api.errorOutput, this._removeThisErrorFocusThisInput);
// Fire each module off conditionally, based on the presence of the targets set on init
this._instantiateByDataValues = (function() { // The core of the script, carefully loadings only each modular bit of functionality by its request in the DOM via data-validate=""
$.each(targetsAll, function( /*iteration*/ ) { /* Iterate through all of the selectors in the targets array, doing the following with each instance of them found in the DOM, passing iteration for debugging purposed only */
var selectorTargetFromArray = $(this);
$.each(selectorTargetFromArray, function() {
var instance = $(this),
thisFnFromDomDataAttrNS = instance.data('validate');
if (instance.length) { // If any of the selectors in the targets array are found to be in the the DOM on init
// Fire the constructor on the element with the data-validate="thisMethod", while passing its value to its action (all method modules and method specific functionality is named based on the selector that is responsible for its instantiation)
this.executeActionByCallerName = new api[thisFnFromDomDataAttrNS].Action(instance.val(), instance);
//! This fires off the action of the module itself off by the name of the value in the data-validate="functionNameHere"
}
else {
this._createNoRunLog = api.results[this] = false; // Store refs to any built in methods not used for your debugging pleasure, under the name it is called by and on
console.log(api.results);
}
});
});
})();
return this;
}; // End preValidation module
Works fine on JSFiddle.. http://jsfiddle.net/exsfabxr/ I guess I can rule out my code as the issue here? Seems that this is an internal Codepen.io issue?
$(function(){ alert('same code as above, i just wrote this because of stackoverflows "smart" validation'); window.location.href = 'http://jsfiddle.net/exsfabxr/'; });

YDN-DB How to update a single field?

I've been trying to update a single field using db.put() but couldn't make it work properly. Every time I update a single field by a given ID, it deletes all other entries. Here is an example code:
var schema = {
stores: [
{
name: 'items',
keyPath: 'id'
},
{
name: 'config',
keyPath: 'id'
}
]
};
var db = new ydn.db.Storage('initial', schema);
var items = [{
id: 1,
itemId: 'GTA5',
date:'03/25/2013',
description:'Great game'
}, {
id: 2,
itemId: 'Simcity',
date:'12/01/2012',
description:'Awesome gameplay'
}];
var config = {
id: 1,
currency: 'USD'
};
db.put('items', items);
db.put('config', config);
var updateTable = function(){
var req = $.when(db.count('items'),db.values('items'),db.get('config', 1));
var disp = function(s) {
var e = document.createElement('div');
e.textContent = s;
document.body.appendChild(e);
};
req.done(function(count,r,config) {
var currency = config.currency;
if(count > 0){
var n = r.length;
for (var i = 0; i < n; i++) {
var id = r[i].id;
var itemId = r[i].itemId;
var date = r[i].date;
var description = r[i].description
disp('ID: '+id+' itemID: '+itemId+' Currency: '+currency+' Date: '+date+' Description: '+description);
}
}
});
}
updateTable();
$('a').click(function(e){
e.preventDefault();
db.put('items',{id:2,description:'Borring'}).done(function(){
updateTable();
});
});
Here is a working example of whats happening JSFiddle. If you click the "change" link, the specified field is updated but all other fields are 'undefined'
Yeah, SimCity is boring now, but Tomorrow will bring excitement again.
IndexedDB is essentially a key-document store. You have to read or write a record as a whole. It is NOT possible to update only certain field(s). Event if you want small update, you have to read and write back whole record.
Reading and write back the whole record is OK, but there is an important consideration for consistency. When you write back, you must ensure that the the record you have was not modified by other thread. Even though javascript is single thread, since both read and write operations are asynchronous and each operation could have different database state. It seems extremely rare, but often happen. For example, when user click, nothing happen and then click again. These user interactions are queued and execute in parallel from async database perspective.
A common technique is using single transaction for both operations. In YDN-DB, you can do in three ways.
Using explicit transaction:
db.run(function(tx_db) {
tx_db.get('items', 2).done(function(item) {
item.description = 'boring until Tomorrow';
tx_db.put(item).done(function(k) {
updateTable();
}
}
}, ['items'], 'readwrite');
Using an atomic database operation:
var iter = ydn.db.ValueIterator.where('items', '=', 2);
db.open(function(cursor) {
var item = cursor.getValue();
item.description = 'boring until Tomorrow';
cursor.update(item);
}, iter, 'readwrite');
EDIT:
Using query wrapper:
db.from('items', '=', 2).patch({description: 'boring until Tomorrow'});

Categories