2018-10-24 00:39:02 +08:00
|
|
|
'use strict';
|
|
|
|
|
2015-09-26 01:04:59 +08:00
|
|
|
var testHelper = require('../../../support/test_helper');
|
2017-08-04 23:51:10 +08:00
|
|
|
var LayergroupToken = require('../../../../lib/cartodb/models/layergroup-token');
|
2015-09-26 01:04:59 +08:00
|
|
|
|
2015-07-08 05:46:58 +08:00
|
|
|
var step = require('step');
|
|
|
|
var assert = require('../../../support/assert');
|
|
|
|
var _ = require('underscore');
|
|
|
|
var querystring = require('querystring');
|
|
|
|
var mapnik = require('windshaft').mapnik;
|
|
|
|
var CartodbServer = require('../../../../lib/cartodb/server');
|
|
|
|
var PortedServerOptions = require('./ported_server_options');
|
|
|
|
|
|
|
|
var DEFAULT_POINT_STYLE = [
|
|
|
|
'#layer {',
|
|
|
|
' marker-fill: #FF6600;',
|
|
|
|
' marker-opacity: 1;',
|
|
|
|
' marker-width: 16;',
|
|
|
|
' marker-line-color: white;',
|
|
|
|
' marker-line-width: 3;',
|
|
|
|
' marker-line-opacity: 0.9;',
|
|
|
|
' marker-placement: point;',
|
|
|
|
' marker-type: ellipse;',
|
|
|
|
' marker-allow-overlap: true;',
|
|
|
|
'}'
|
|
|
|
].join('');
|
|
|
|
|
|
|
|
module.exports = {
|
|
|
|
createLayergroup: createLayergroup,
|
|
|
|
withLayergroup: withLayergroup,
|
|
|
|
|
|
|
|
singleLayerMapConfig: singleLayerMapConfig,
|
|
|
|
defaultTableMapConfig: defaultTableMapConfig,
|
|
|
|
|
|
|
|
getStaticBbox: getStaticBbox,
|
|
|
|
getStaticCenter: getStaticCenter,
|
|
|
|
getGrid: getGrid,
|
|
|
|
getGridJsonp: getGridJsonp,
|
|
|
|
getTorque: getTorque,
|
|
|
|
getTile: getTile,
|
|
|
|
getTileLayer: getTileLayer
|
|
|
|
};
|
|
|
|
|
2018-04-16 22:16:23 +08:00
|
|
|
var server;
|
2015-07-08 05:46:58 +08:00
|
|
|
|
2018-04-16 22:16:23 +08:00
|
|
|
function getServer () {
|
|
|
|
if (server) {
|
|
|
|
return server;
|
|
|
|
}
|
|
|
|
|
|
|
|
server = new CartodbServer(PortedServerOptions);
|
|
|
|
server.setMaxListeners(0);
|
|
|
|
|
|
|
|
return server;
|
|
|
|
}
|
2015-07-08 05:46:58 +08:00
|
|
|
|
|
|
|
var jsonContentType = 'application/json; charset=utf-8';
|
2015-09-17 08:06:32 +08:00
|
|
|
var jsContentType = 'text/javascript; charset=utf-8';
|
2015-07-08 05:46:58 +08:00
|
|
|
var pngContentType = 'image/png';
|
|
|
|
|
|
|
|
function createLayergroup(layergroupConfig, options, callback) {
|
|
|
|
if (!callback) {
|
|
|
|
callback = options;
|
|
|
|
options = {
|
|
|
|
method: 'POST',
|
|
|
|
statusCode: 200
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
var expectedResponse = {
|
|
|
|
status: options.statusCode || 200,
|
|
|
|
headers: options.headers || {
|
|
|
|
'Content-Type': 'application/json; charset=utf-8'
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
step(
|
|
|
|
function requestLayergroup() {
|
|
|
|
var next = this;
|
|
|
|
var request = layergroupRequest(layergroupConfig, options.method, options.callbackName, options.params);
|
|
|
|
assert.response(serverInstance(options), request, expectedResponse, function (res, err) {
|
|
|
|
next(err, res);
|
|
|
|
});
|
|
|
|
},
|
|
|
|
function validateLayergroup(err, res) {
|
2018-03-17 01:06:31 +08:00
|
|
|
assert.ifError(err);
|
2015-07-08 05:46:58 +08:00
|
|
|
|
|
|
|
var parsedBody;
|
|
|
|
var layergroupid;
|
2015-09-18 00:12:45 +08:00
|
|
|
if (options.callbackName) {
|
|
|
|
global[options.callbackName] = function(layergroup) {
|
|
|
|
layergroupid = layergroup.layergroupid;
|
|
|
|
};
|
|
|
|
// jshint ignore:start
|
|
|
|
eval(res.body);
|
|
|
|
// jshint ignore:end
|
|
|
|
delete global[options.callbackName];
|
|
|
|
} else {
|
2015-07-08 05:46:58 +08:00
|
|
|
parsedBody = JSON.parse(res.body);
|
|
|
|
layergroupid = parsedBody.layergroupid;
|
2015-09-18 00:12:45 +08:00
|
|
|
if (layergroupid) {
|
2015-09-26 01:04:59 +08:00
|
|
|
layergroupid = LayergroupToken.parse(layergroupid).token;
|
2015-09-18 00:12:45 +08:00
|
|
|
}
|
2015-07-08 05:46:58 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
if (layergroupid) {
|
2015-09-26 01:04:59 +08:00
|
|
|
var keysToDelete = {
|
|
|
|
'user:localhost:mapviews:global': 5
|
|
|
|
};
|
2015-07-08 05:46:58 +08:00
|
|
|
var redisKey = 'map_cfg|' + layergroupid;
|
2015-09-26 01:04:59 +08:00
|
|
|
keysToDelete[redisKey] = 0;
|
|
|
|
testHelper.deleteRedisKeys(keysToDelete, function() {
|
2015-07-08 05:46:58 +08:00
|
|
|
return callback(err, res, parsedBody);
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
return callback(err, res, parsedBody);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
function serverInstance(options) {
|
|
|
|
if (options.server) {
|
|
|
|
return options.server;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (options.serverOptions) {
|
|
|
|
var otherServer = new CartodbServer(options.serverOptions);
|
|
|
|
otherServer.req2params = options.serverOptions.req2params;
|
|
|
|
otherServer.setMaxListeners(0);
|
|
|
|
return otherServer;
|
|
|
|
}
|
|
|
|
|
2018-10-24 00:55:31 +08:00
|
|
|
return getServer();
|
2015-07-08 05:46:58 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
function layergroupRequest(layergroupConfig, method, callbackName, extraParams) {
|
|
|
|
method = method || 'POST';
|
|
|
|
|
|
|
|
var request = {
|
2019-10-02 01:34:03 +08:00
|
|
|
url: '/api/v1/map',
|
2015-07-08 05:46:58 +08:00
|
|
|
headers: {
|
2018-03-17 01:06:31 +08:00
|
|
|
host: 'localhost',
|
2015-07-08 05:46:58 +08:00
|
|
|
'Content-Type': 'application/json'
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
var urlParams = _.extend({}, extraParams);
|
|
|
|
if (callbackName) {
|
|
|
|
urlParams.callback = callbackName;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (method.toUpperCase() === 'GET') {
|
|
|
|
request.method = 'GET';
|
|
|
|
urlParams.config = JSON.stringify(layergroupConfig);
|
|
|
|
} else {
|
|
|
|
request.method = 'POST';
|
|
|
|
request.data = JSON.stringify(layergroupConfig);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (Object.keys(urlParams).length) {
|
|
|
|
request.url += '?' + querystring.stringify(urlParams);
|
|
|
|
}
|
|
|
|
|
|
|
|
return request;
|
|
|
|
}
|
|
|
|
|
|
|
|
function singleLayerMapConfig(sql, cartocss, cartocssVersion, interactivity) {
|
|
|
|
return {
|
|
|
|
version: '1.3.0',
|
|
|
|
layers: [
|
|
|
|
{
|
|
|
|
type: 'mapnik',
|
|
|
|
options: {
|
|
|
|
sql: sql,
|
|
|
|
cartocss: cartocss || DEFAULT_POINT_STYLE,
|
|
|
|
cartocss_version: cartocssVersion || '2.3.0',
|
|
|
|
interactivity: interactivity,
|
|
|
|
geom_column: 'the_geom'
|
|
|
|
}
|
|
|
|
}
|
|
|
|
]
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
function defaultTableMapConfig(tableName, cartocss, cartocssVersion, interactivity) {
|
|
|
|
return singleLayerMapConfig(defaultTableQuery(tableName), cartocss, cartocssVersion, interactivity);
|
|
|
|
}
|
|
|
|
|
|
|
|
function defaultTableQuery(tableName) {
|
|
|
|
return _.template('SELECT * FROM <%= tableName %>', {tableName: tableName});
|
|
|
|
}
|
|
|
|
|
|
|
|
function getStaticBbox(layergroupConfig, west, south, east, north, width, height, expectedResponse, callback) {
|
|
|
|
if (!callback) {
|
|
|
|
callback = expectedResponse;
|
|
|
|
expectedResponse = pngContentType;
|
|
|
|
}
|
|
|
|
|
|
|
|
var url = [
|
|
|
|
'static',
|
|
|
|
'bbox',
|
|
|
|
'<%= layergroupid %>',
|
|
|
|
[west, south, east, north].join(','),
|
|
|
|
width,
|
|
|
|
height
|
|
|
|
].join('/') + '.png';
|
|
|
|
return getGeneric(layergroupConfig, url, expectedResponse, callback);
|
|
|
|
}
|
|
|
|
|
|
|
|
function getStaticCenter(layergroupConfig, zoom, lat, lon, width, height, expectedResponse, callback) {
|
|
|
|
if (!callback) {
|
|
|
|
callback = expectedResponse;
|
|
|
|
expectedResponse = pngContentType;
|
|
|
|
}
|
|
|
|
|
|
|
|
var url = [
|
|
|
|
'static',
|
|
|
|
'center',
|
|
|
|
'<%= layergroupid %>',
|
|
|
|
zoom,
|
|
|
|
lat,
|
|
|
|
lon,
|
|
|
|
width,
|
|
|
|
height
|
|
|
|
].join('/') + '.png';
|
|
|
|
return getGeneric(layergroupConfig, url, expectedResponse, callback);
|
|
|
|
}
|
|
|
|
|
|
|
|
function getGrid(layergroupConfig, layer, z, x, y, expectedResponse, callback) {
|
|
|
|
if (!callback) {
|
|
|
|
callback = expectedResponse;
|
|
|
|
expectedResponse = jsonContentType;
|
|
|
|
}
|
|
|
|
|
|
|
|
var options = {
|
|
|
|
layer: layer,
|
|
|
|
z: z,
|
|
|
|
x: x,
|
|
|
|
y: y,
|
|
|
|
format: 'grid.json'
|
|
|
|
};
|
|
|
|
return getLayer(layergroupConfig, options, expectedResponse, callback);
|
|
|
|
}
|
|
|
|
|
|
|
|
function getGridJsonp(layergroupConfig, layer, z, x, y, jsonpCallbackName, expectedResponse, callback) {
|
|
|
|
if (!callback) {
|
|
|
|
callback = expectedResponse;
|
2015-09-17 08:06:32 +08:00
|
|
|
expectedResponse = jsContentType;
|
2015-07-08 05:46:58 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
var options = {
|
|
|
|
layer: layer,
|
|
|
|
z: z,
|
|
|
|
x: x,
|
|
|
|
y: y,
|
|
|
|
format: 'grid.json',
|
|
|
|
jsonpCallbackName: jsonpCallbackName
|
|
|
|
};
|
|
|
|
return getLayer(layergroupConfig, options, expectedResponse, callback);
|
|
|
|
}
|
|
|
|
|
|
|
|
function getTorque(layergroupConfig, layer, z, x, y, expectedResponse, callback) {
|
|
|
|
if (!callback) {
|
|
|
|
callback = expectedResponse;
|
|
|
|
expectedResponse = jsonContentType;
|
|
|
|
}
|
|
|
|
|
|
|
|
var options = {
|
|
|
|
layer: layer,
|
|
|
|
z: z,
|
|
|
|
x: x,
|
|
|
|
y: y,
|
|
|
|
format: 'torque.json'
|
|
|
|
};
|
|
|
|
return getLayer(layergroupConfig, options, expectedResponse, callback);
|
|
|
|
}
|
|
|
|
|
|
|
|
function getTile(layergroupConfig, z, x, y, expectedResponse, callback) {
|
|
|
|
if (!callback) {
|
|
|
|
callback = expectedResponse;
|
|
|
|
expectedResponse = pngContentType;
|
|
|
|
}
|
|
|
|
|
|
|
|
var options = {
|
|
|
|
z: z,
|
|
|
|
x: x,
|
|
|
|
y: y,
|
|
|
|
format: 'png'
|
|
|
|
};
|
|
|
|
return getLayer(layergroupConfig, options, expectedResponse, callback);
|
|
|
|
}
|
|
|
|
|
|
|
|
function getTileLayer(layergroupConfig, options, expectedResponse, callback) {
|
|
|
|
if (!callback) {
|
|
|
|
callback = expectedResponse;
|
|
|
|
expectedResponse = pngContentType;
|
|
|
|
}
|
|
|
|
|
|
|
|
return getLayer(layergroupConfig, options, expectedResponse, callback);
|
|
|
|
}
|
|
|
|
|
|
|
|
function getLayer(layergroupConfig, options, expectedResponse, callback) {
|
|
|
|
return getGeneric(layergroupConfig, tileUrlStrategy(options), expectedResponse, callback);
|
|
|
|
}
|
|
|
|
|
|
|
|
function tileUrlStrategy(options) {
|
|
|
|
var urlLayerPattern = [
|
|
|
|
'<%= layer %>',
|
|
|
|
'<%= z %>',
|
|
|
|
'<%= x %>',
|
|
|
|
'<%= y %>'
|
|
|
|
].join('/') + '.<%= format %>';
|
|
|
|
|
|
|
|
if (options.jsonpCallbackName) {
|
|
|
|
urlLayerPattern += '?callback=<%= jsonpCallbackName %>';
|
|
|
|
}
|
|
|
|
|
|
|
|
var urlNoLayerPattern = [
|
|
|
|
'<%= z %>',
|
|
|
|
'<%= x %>',
|
|
|
|
'<%= y %>'
|
|
|
|
].join('/') + '.<%= format %>';
|
|
|
|
|
|
|
|
var urlTemplate = _.template((options.layer === undefined) ? urlNoLayerPattern : urlLayerPattern);
|
|
|
|
|
|
|
|
options.format = options.format || 'png';
|
|
|
|
|
|
|
|
return '<%= layergroupid %>/' + urlTemplate(_.defaults(options, { z: 0, x: 0, y: 0, layer: 0 }));
|
|
|
|
}
|
|
|
|
|
|
|
|
function getGeneric(layergroupConfig, url, expectedResponse, callback) {
|
|
|
|
if (_.isString(expectedResponse)) {
|
|
|
|
expectedResponse = {
|
|
|
|
status: 200,
|
|
|
|
headers: {
|
|
|
|
'Content-Type': expectedResponse
|
|
|
|
}
|
|
|
|
};
|
|
|
|
}
|
|
|
|
var contentType = expectedResponse.headers['Content-Type'];
|
|
|
|
|
|
|
|
var layergroupid = null;
|
|
|
|
|
|
|
|
step(
|
|
|
|
function requestLayergroup() {
|
|
|
|
var next = this;
|
|
|
|
var request = {
|
2019-10-02 01:34:03 +08:00
|
|
|
url: '/api/v1/map',
|
2015-07-08 05:46:58 +08:00
|
|
|
method: 'POST',
|
|
|
|
headers: {
|
2018-03-17 00:55:04 +08:00
|
|
|
host: 'localhost',
|
2015-07-08 05:46:58 +08:00
|
|
|
'Content-Type': 'application/json'
|
|
|
|
},
|
|
|
|
data: JSON.stringify(layergroupConfig)
|
|
|
|
};
|
|
|
|
var expectedResponse = {
|
|
|
|
status: 200,
|
|
|
|
headers: {
|
|
|
|
'Content-Type': 'application/json; charset=utf-8'
|
|
|
|
}
|
|
|
|
};
|
2018-04-16 22:16:23 +08:00
|
|
|
assert.response(getServer(), request, expectedResponse, function (res, err) {
|
2015-07-08 05:46:58 +08:00
|
|
|
next(err, res);
|
|
|
|
});
|
|
|
|
},
|
|
|
|
function validateLayergroup(err, res) {
|
|
|
|
assert.ok(!err, 'Failed to create layergroup');
|
|
|
|
|
|
|
|
var parsedBody = JSON.parse(res.body);
|
|
|
|
layergroupid = parsedBody.layergroupid;
|
|
|
|
|
|
|
|
assert.ok(layergroupid);
|
|
|
|
|
|
|
|
return res;
|
|
|
|
},
|
|
|
|
function requestTile(err, res) {
|
|
|
|
assert.ok(!err, 'Invalid layergroup response: ' + res.body);
|
|
|
|
|
|
|
|
var next = this;
|
|
|
|
|
2019-10-02 01:34:03 +08:00
|
|
|
var finalUrl = '/api/v1/map/' + _.template(url, {
|
2015-07-08 05:46:58 +08:00
|
|
|
layergroupid: layergroupid
|
|
|
|
});
|
|
|
|
|
|
|
|
var request = {
|
|
|
|
url: finalUrl,
|
2018-03-17 00:55:04 +08:00
|
|
|
method: 'GET',
|
|
|
|
headers: {
|
|
|
|
host: 'localhost'
|
|
|
|
}
|
2015-07-08 05:46:58 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
if (contentType === pngContentType) {
|
|
|
|
request.encoding = 'binary';
|
|
|
|
}
|
|
|
|
|
2018-04-16 22:16:23 +08:00
|
|
|
assert.response(getServer(), request, expectedResponse, function (res, err) {
|
2015-07-08 05:46:58 +08:00
|
|
|
next(err, res);
|
|
|
|
});
|
|
|
|
},
|
|
|
|
function validateTile(err, res) {
|
|
|
|
assert.ok(!err, 'Failed to get tile');
|
|
|
|
|
|
|
|
var img;
|
|
|
|
if (contentType === pngContentType) {
|
|
|
|
img = new mapnik.Image.fromBytesSync(new Buffer(res.body, 'binary'));
|
|
|
|
}
|
|
|
|
|
2015-09-26 01:04:59 +08:00
|
|
|
var keysToDelete = {
|
|
|
|
'user:localhost:mapviews:global': 5
|
|
|
|
};
|
|
|
|
var redisKey = 'map_cfg|' + LayergroupToken.parse(layergroupid).token;
|
|
|
|
keysToDelete[redisKey] = 0;
|
|
|
|
testHelper.deleteRedisKeys(keysToDelete, function() {
|
2015-07-08 05:46:58 +08:00
|
|
|
return callback(err, res, img);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
function withLayergroup(layergroupConfig, options, callback) {
|
|
|
|
var validationLayergroupFn = function() {};
|
|
|
|
if (!callback) {
|
|
|
|
callback = options;
|
|
|
|
options = {};
|
|
|
|
}
|
|
|
|
|
|
|
|
if (_.isFunction(options)) {
|
|
|
|
validationLayergroupFn = options;
|
|
|
|
options = {};
|
|
|
|
}
|
|
|
|
|
|
|
|
var layergroupExpectedResponse = {
|
|
|
|
status: 200,
|
|
|
|
headers: {
|
|
|
|
'Content-Type': 'application/json; charset=utf-8'
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
step(
|
|
|
|
function requestLayergroup() {
|
|
|
|
var next = this;
|
|
|
|
var request = layergroupRequest(layergroupConfig, 'POST');
|
|
|
|
assert.response(serverInstance(options), request, layergroupExpectedResponse, function (res, err) {
|
|
|
|
next(err, res);
|
|
|
|
});
|
|
|
|
},
|
|
|
|
function validateLayergroup(err, res) {
|
|
|
|
assert.ok(!err, 'Failed to request layergroup');
|
|
|
|
|
|
|
|
var parsedBody = JSON.parse(res.body);
|
|
|
|
var layergroupid = parsedBody.layergroupid;
|
|
|
|
|
|
|
|
assert.ok(layergroupid, 'No layergroup was created');
|
|
|
|
|
|
|
|
validationLayergroupFn(res);
|
|
|
|
|
|
|
|
function requestTile(layergroupUrl, options, callback) {
|
|
|
|
if (!callback) {
|
|
|
|
callback = options;
|
|
|
|
options = {
|
|
|
|
statusCode: 200,
|
|
|
|
contentType: pngContentType
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2018-03-19 20:51:03 +08:00
|
|
|
const signerTpl = function ({ signer }) {
|
|
|
|
return `${signer ? `:${signer}@` : ''}`;
|
2018-03-20 18:09:05 +08:00
|
|
|
};
|
2018-03-19 20:51:03 +08:00
|
|
|
|
|
|
|
const cacheTpl = function ({ cache_buster, cacheBuster }) {
|
|
|
|
return `${cache_buster ? `:${cache_buster}` : `:${cacheBuster}`}`;
|
2018-03-20 18:09:05 +08:00
|
|
|
};
|
2018-03-19 20:51:03 +08:00
|
|
|
|
|
|
|
const urlTpl = function ({layergroupid, cache_buster = null, tile }) {
|
|
|
|
const { signer, token , cacheBuster } = LayergroupToken.parse(layergroupid);
|
2019-10-02 01:34:03 +08:00
|
|
|
const base = '/api/v1/map/';
|
2018-03-19 20:51:03 +08:00
|
|
|
return `${base}${signerTpl({signer})}${token}${cacheTpl({cache_buster, cacheBuster})}${tile}`;
|
2018-03-20 18:09:05 +08:00
|
|
|
};
|
2018-03-19 20:51:03 +08:00
|
|
|
|
|
|
|
const finalUrl = urlTpl({ layergroupid, cache_buster: options.cache_buster, tile: layergroupUrl });
|
2015-07-08 05:46:58 +08:00
|
|
|
|
|
|
|
var request = {
|
|
|
|
url: finalUrl,
|
2018-03-17 01:59:07 +08:00
|
|
|
method: 'GET',
|
|
|
|
headers: {
|
|
|
|
host: 'localhost'
|
|
|
|
}
|
2015-07-08 05:46:58 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
if (options.contentType === pngContentType) {
|
|
|
|
request.encoding = 'binary';
|
|
|
|
}
|
|
|
|
|
|
|
|
var tileExpectedResponse = {
|
|
|
|
status: options.statusCode || 200,
|
|
|
|
headers: {
|
|
|
|
'Content-Type': options.contentType || pngContentType
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
assert.response(serverInstance(options), request, tileExpectedResponse, function (res, err) {
|
|
|
|
callback(err, res);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
function finish(done) {
|
2015-09-26 01:11:48 +08:00
|
|
|
var keysToDelete = {
|
|
|
|
'user:localhost:mapviews:global': 5
|
|
|
|
};
|
|
|
|
var redisKey = 'map_cfg|' + LayergroupToken.parse(layergroupid).token;
|
|
|
|
keysToDelete[redisKey] = 0;
|
|
|
|
testHelper.deleteRedisKeys(keysToDelete, done);
|
2015-07-08 05:46:58 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
return callback(err, requestTile, finish);
|
|
|
|
}
|
|
|
|
);
|
|
|
|
}
|