2017-05-24 10:39:33 +03:00
|
|
|
/*
|
|
|
|
|
* Copyright © 2016-2017 The Thingsboard Authors
|
|
|
|
|
*
|
|
|
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
|
* you may not use this file except in compliance with the License.
|
|
|
|
|
* You may obtain a copy of the License at
|
|
|
|
|
*
|
|
|
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
|
*
|
|
|
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
|
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
|
* See the License for the specific language governing permissions and
|
|
|
|
|
* limitations under the License.
|
|
|
|
|
*/
|
|
|
|
|
import 'angular-material-data-table/dist/md-data-table.min.css';
|
|
|
|
|
import './attribute-table.scss';
|
|
|
|
|
|
|
|
|
|
/* eslint-disable import/no-unresolved, import/default */
|
|
|
|
|
|
|
|
|
|
import attributeTableTemplate from './attribute-table.tpl.html';
|
|
|
|
|
import addAttributeDialogTemplate from './add-attribute-dialog.tpl.html';
|
|
|
|
|
import addWidgetToDashboardDialogTemplate from './add-widget-to-dashboard-dialog.tpl.html';
|
|
|
|
|
import editAttributeValueTemplate from './edit-attribute-value.tpl.html';
|
|
|
|
|
|
|
|
|
|
/* eslint-enable import/no-unresolved, import/default */
|
|
|
|
|
|
|
|
|
|
import EditAttributeValueController from './edit-attribute-value.controller';
|
2017-06-08 21:15:47 +03:00
|
|
|
import AliasController from '../../api/alias-controller';
|
2017-05-24 10:39:33 +03:00
|
|
|
|
|
|
|
|
/*@ngInject*/
|
|
|
|
|
export default function AttributeTableDirective($compile, $templateCache, $rootScope, $q, $mdEditDialog, $mdDialog,
|
2017-06-08 21:15:47 +03:00
|
|
|
$mdUtil, $document, $translate, $filter, utils, types, dashboardUtils,
|
|
|
|
|
dashboardService, entityService, attributeService, widgetService) {
|
2017-05-24 10:39:33 +03:00
|
|
|
|
|
|
|
|
var linker = function (scope, element, attrs) {
|
|
|
|
|
|
|
|
|
|
var template = $templateCache.get(attributeTableTemplate);
|
|
|
|
|
|
|
|
|
|
element.html(template);
|
|
|
|
|
|
|
|
|
|
var getAttributeScopeByValue = function(attributeScopeValue) {
|
|
|
|
|
if (scope.types.latestTelemetry.value === attributeScopeValue) {
|
|
|
|
|
return scope.types.latestTelemetry;
|
|
|
|
|
}
|
|
|
|
|
for (var attrScope in scope.attributeScopes) {
|
|
|
|
|
if (scope.attributeScopes[attrScope].value === attributeScopeValue) {
|
|
|
|
|
return scope.attributeScopes[attrScope];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scope.types = types;
|
|
|
|
|
|
|
|
|
|
scope.entityType = attrs.entityType;
|
|
|
|
|
|
|
|
|
|
if (scope.entityType === types.entityType.device) {
|
|
|
|
|
scope.attributeScopes = types.attributesScope;
|
|
|
|
|
scope.attributeScopeSelectionReadonly = false;
|
|
|
|
|
} else {
|
|
|
|
|
scope.attributeScopes = {};
|
|
|
|
|
scope.attributeScopes.server = types.attributesScope.server;
|
|
|
|
|
scope.attributeScopeSelectionReadonly = true;
|
2017-06-08 12:20:41 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scope.attributeScope = getAttributeScopeByValue(attrs.defaultAttributeScope);
|
|
|
|
|
|
|
|
|
|
if (scope.entityType != types.entityType.device) {
|
2017-05-24 10:39:33 +03:00
|
|
|
if (scope.attributeScope != types.latestTelemetry) {
|
2017-06-08 12:20:41 +03:00
|
|
|
scope.attributeScope = scope.attributeScopes.server;
|
2017-05-24 10:39:33 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scope.attributes = {
|
|
|
|
|
count: 0,
|
|
|
|
|
data: []
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
scope.selectedAttributes = [];
|
|
|
|
|
scope.mode = 'default'; // 'widget'
|
|
|
|
|
scope.subscriptionId = null;
|
|
|
|
|
|
|
|
|
|
scope.query = {
|
|
|
|
|
order: 'key',
|
|
|
|
|
limit: 5,
|
|
|
|
|
page: 1,
|
|
|
|
|
search: null
|
|
|
|
|
};
|
|
|
|
|
|
2017-06-08 12:20:41 +03:00
|
|
|
scope.$watch("entityId", function(newVal) {
|
|
|
|
|
if (newVal) {
|
2017-05-24 10:39:33 +03:00
|
|
|
scope.resetFilter();
|
|
|
|
|
scope.getEntityAttributes(false, true);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
scope.$watch("attributeScope", function(newVal, prevVal) {
|
|
|
|
|
if (newVal && !angular.equals(newVal, prevVal)) {
|
|
|
|
|
scope.mode = 'default';
|
|
|
|
|
scope.query.search = null;
|
|
|
|
|
scope.selectedAttributes = [];
|
|
|
|
|
scope.getEntityAttributes(false, true);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
scope.resetFilter = function() {
|
|
|
|
|
scope.mode = 'default';
|
|
|
|
|
scope.query.search = null;
|
|
|
|
|
scope.selectedAttributes = [];
|
|
|
|
|
scope.attributeScope = getAttributeScopeByValue(attrs.defaultAttributeScope);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scope.enterFilterMode = function() {
|
|
|
|
|
scope.query.search = '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scope.exitFilterMode = function() {
|
|
|
|
|
scope.query.search = null;
|
|
|
|
|
scope.getEntityAttributes();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scope.$watch("query.search", function(newVal, prevVal) {
|
|
|
|
|
if (!angular.equals(newVal, prevVal) && scope.query.search != null) {
|
|
|
|
|
scope.getEntityAttributes();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function success(attributes, update, apply) {
|
|
|
|
|
scope.attributes = attributes;
|
|
|
|
|
if (!update) {
|
|
|
|
|
scope.selectedAttributes = [];
|
|
|
|
|
}
|
|
|
|
|
if (apply) {
|
|
|
|
|
scope.$digest();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scope.onReorder = function() {
|
|
|
|
|
scope.getEntityAttributes(false, false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scope.onPaginate = function() {
|
|
|
|
|
scope.getEntityAttributes(false, false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scope.getEntityAttributes = function(forceUpdate, reset) {
|
|
|
|
|
if (scope.attributesDeferred) {
|
|
|
|
|
scope.attributesDeferred.resolve();
|
|
|
|
|
}
|
|
|
|
|
if (scope.entityId && scope.entityType && scope.attributeScope) {
|
|
|
|
|
if (reset) {
|
|
|
|
|
scope.attributes = {
|
|
|
|
|
count: 0,
|
|
|
|
|
data: []
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
scope.checkSubscription();
|
|
|
|
|
scope.attributesDeferred = attributeService.getEntityAttributes(scope.entityType, scope.entityId, scope.attributeScope.value,
|
|
|
|
|
scope.query, function(attributes, update, apply) {
|
|
|
|
|
success(attributes, update || forceUpdate, apply);
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
var deferred = $q.defer();
|
|
|
|
|
scope.attributesDeferred = deferred;
|
|
|
|
|
success({
|
|
|
|
|
count: 0,
|
|
|
|
|
data: []
|
|
|
|
|
});
|
|
|
|
|
deferred.resolve();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scope.checkSubscription = function() {
|
|
|
|
|
var newSubscriptionId = null;
|
|
|
|
|
if (scope.entityId && scope.entityType && scope.attributeScope.clientSide && scope.mode != 'widget') {
|
|
|
|
|
newSubscriptionId = attributeService.subscribeForEntityAttributes(scope.entityType, scope.entityId, scope.attributeScope.value);
|
|
|
|
|
}
|
|
|
|
|
if (scope.subscriptionId && scope.subscriptionId != newSubscriptionId) {
|
|
|
|
|
attributeService.unsubscribeForEntityAttributes(scope.subscriptionId);
|
|
|
|
|
}
|
|
|
|
|
scope.subscriptionId = newSubscriptionId;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scope.$on('$destroy', function() {
|
|
|
|
|
if (scope.subscriptionId) {
|
|
|
|
|
attributeService.unsubscribeForEntityAttributes(scope.subscriptionId);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
scope.editAttribute = function($event, attribute) {
|
|
|
|
|
if (!scope.attributeScope.clientSide) {
|
|
|
|
|
$event.stopPropagation();
|
|
|
|
|
$mdEditDialog.show({
|
|
|
|
|
controller: EditAttributeValueController,
|
|
|
|
|
templateUrl: editAttributeValueTemplate,
|
|
|
|
|
locals: {attributeValue: attribute.value,
|
|
|
|
|
save: function (model) {
|
|
|
|
|
var updatedAttribute = angular.copy(attribute);
|
|
|
|
|
updatedAttribute.value = model.value;
|
|
|
|
|
attributeService.saveEntityAttributes(scope.entityType, scope.entityId, scope.attributeScope.value, [updatedAttribute]).then(
|
|
|
|
|
function success() {
|
|
|
|
|
scope.getEntityAttributes();
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
}},
|
|
|
|
|
targetEvent: $event
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scope.addAttribute = function($event) {
|
|
|
|
|
if (!scope.attributeScope.clientSide) {
|
|
|
|
|
$event.stopPropagation();
|
|
|
|
|
$mdDialog.show({
|
|
|
|
|
controller: 'AddAttributeDialogController',
|
|
|
|
|
controllerAs: 'vm',
|
|
|
|
|
templateUrl: addAttributeDialogTemplate,
|
|
|
|
|
parent: angular.element($document[0].body),
|
|
|
|
|
locals: {entityType: scope.entityType, entityId: scope.entityId, attributeScope: scope.attributeScope.value},
|
|
|
|
|
fullscreen: true,
|
|
|
|
|
targetEvent: $event
|
|
|
|
|
}).then(function () {
|
|
|
|
|
scope.getEntityAttributes();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scope.deleteAttributes = function($event) {
|
|
|
|
|
if (!scope.attributeScope.clientSide) {
|
|
|
|
|
$event.stopPropagation();
|
|
|
|
|
var confirm = $mdDialog.confirm()
|
|
|
|
|
.targetEvent($event)
|
|
|
|
|
.title($translate.instant('attribute.delete-attributes-title', {count: scope.selectedAttributes.length}, 'messageformat'))
|
|
|
|
|
.htmlContent($translate.instant('attribute.delete-attributes-text'))
|
|
|
|
|
.ariaLabel($translate.instant('attribute.delete-attributes'))
|
|
|
|
|
.cancel($translate.instant('action.no'))
|
|
|
|
|
.ok($translate.instant('action.yes'));
|
|
|
|
|
$mdDialog.show(confirm).then(function () {
|
|
|
|
|
attributeService.deleteEntityAttributes(scope.entityType, scope.entityId, scope.attributeScope.value, scope.selectedAttributes).then(
|
|
|
|
|
function success() {
|
|
|
|
|
scope.selectedAttributes = [];
|
|
|
|
|
scope.getEntityAttributes();
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scope.nextWidget = function() {
|
2017-06-08 21:15:47 +03:00
|
|
|
$mdUtil.nextTick(function () {
|
|
|
|
|
if (scope.widgetsCarousel.index < scope.widgetsList.length - 1) {
|
|
|
|
|
scope.widgetsCarousel.index++;
|
|
|
|
|
}
|
|
|
|
|
});
|
2017-05-24 10:39:33 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scope.prevWidget = function() {
|
2017-06-08 21:15:47 +03:00
|
|
|
$mdUtil.nextTick(function () {
|
|
|
|
|
if (scope.widgetsCarousel.index > 0) {
|
|
|
|
|
scope.widgetsCarousel.index--;
|
|
|
|
|
}
|
|
|
|
|
});
|
2017-05-24 10:39:33 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scope.enterWidgetMode = function() {
|
|
|
|
|
|
|
|
|
|
if (scope.widgetsIndexWatch) {
|
|
|
|
|
scope.widgetsIndexWatch();
|
|
|
|
|
scope.widgetsIndexWatch = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (scope.widgetsBundleWatch) {
|
|
|
|
|
scope.widgetsBundleWatch();
|
|
|
|
|
scope.widgetsBundleWatch = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scope.mode = 'widget';
|
|
|
|
|
scope.checkSubscription();
|
|
|
|
|
scope.widgetsList = [];
|
|
|
|
|
scope.widgetsListCache = [];
|
|
|
|
|
scope.widgetsLoaded = false;
|
|
|
|
|
scope.widgetsCarousel = {
|
|
|
|
|
index: 0
|
|
|
|
|
}
|
|
|
|
|
scope.widgetsBundle = null;
|
|
|
|
|
scope.firstBundle = true;
|
|
|
|
|
scope.selectedWidgetsBundleAlias = types.systemBundleAlias.cards;
|
|
|
|
|
|
2017-06-08 21:15:47 +03:00
|
|
|
var entityAlias = {
|
|
|
|
|
id: utils.guid(),
|
|
|
|
|
alias: scope.entityName,
|
|
|
|
|
filter: dashboardUtils.createSingleEntityFilter(scope.entityType, scope.entityId)
|
|
|
|
|
};
|
|
|
|
|
var entitiAliases = {};
|
|
|
|
|
entitiAliases[entityAlias.id] = entityAlias;
|
|
|
|
|
|
|
|
|
|
var stateController = {
|
|
|
|
|
getStateParams: function() {
|
|
|
|
|
return {};
|
2017-05-24 10:39:33 +03:00
|
|
|
}
|
|
|
|
|
};
|
2017-06-08 21:15:47 +03:00
|
|
|
scope.aliasController = new AliasController(scope, $q, $filter, utils,
|
|
|
|
|
types, entityService, stateController, entitiAliases);
|
2017-05-24 10:39:33 +03:00
|
|
|
|
|
|
|
|
var dataKeyType = scope.attributeScope === types.latestTelemetry ?
|
|
|
|
|
types.dataKeyType.timeseries : types.dataKeyType.attribute;
|
|
|
|
|
|
|
|
|
|
var datasource = {
|
|
|
|
|
type: types.datasourceType.entity,
|
2017-06-08 21:15:47 +03:00
|
|
|
entityAliasId: entityAlias.id,
|
2017-05-24 10:39:33 +03:00
|
|
|
dataKeys: []
|
|
|
|
|
}
|
|
|
|
|
var i = 0;
|
|
|
|
|
for (var attr =0; attr < scope.selectedAttributes.length;attr++) {
|
|
|
|
|
var attribute = scope.selectedAttributes[attr];
|
|
|
|
|
var dataKey = {
|
|
|
|
|
name: attribute.key,
|
|
|
|
|
label: attribute.key,
|
|
|
|
|
type: dataKeyType,
|
|
|
|
|
color: utils.getMaterialColor(i),
|
|
|
|
|
settings: {},
|
|
|
|
|
_hash: Math.random()
|
|
|
|
|
}
|
|
|
|
|
datasource.dataKeys.push(dataKey);
|
|
|
|
|
i++;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scope.widgetsIndexWatch = scope.$watch('widgetsCarousel.index', function(newVal, prevVal) {
|
|
|
|
|
if (scope.mode === 'widget' && (newVal != prevVal)) {
|
|
|
|
|
var index = scope.widgetsCarousel.index;
|
|
|
|
|
for (var i = 0; i < scope.widgetsList.length; i++) {
|
|
|
|
|
scope.widgetsList[i].splice(0, scope.widgetsList[i].length);
|
|
|
|
|
if (i === index) {
|
|
|
|
|
scope.widgetsList[i].push(scope.widgetsListCache[i][0]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
scope.widgetsBundleWatch = scope.$watch('widgetsBundle', function(newVal, prevVal) {
|
|
|
|
|
if (scope.mode === 'widget' && (scope.firstBundle === true || newVal != prevVal)) {
|
|
|
|
|
scope.widgetsList = [];
|
|
|
|
|
scope.widgetsListCache = [];
|
|
|
|
|
scope.widgetsCarousel.index = 0;
|
|
|
|
|
scope.firstBundle = false;
|
|
|
|
|
if (scope.widgetsBundle) {
|
|
|
|
|
scope.widgetsLoaded = false;
|
|
|
|
|
var bundleAlias = scope.widgetsBundle.alias;
|
|
|
|
|
var isSystem = scope.widgetsBundle.tenantId.id === types.id.nullUid;
|
|
|
|
|
widgetService.getBundleWidgetTypes(scope.widgetsBundle.alias, isSystem).then(
|
|
|
|
|
function success(widgetTypes) {
|
|
|
|
|
|
|
|
|
|
widgetTypes = $filter('orderBy')(widgetTypes, ['-descriptor.type','-createdTime']);
|
|
|
|
|
|
|
|
|
|
for (var i = 0; i < widgetTypes.length; i++) {
|
|
|
|
|
var widgetType = widgetTypes[i];
|
|
|
|
|
var widgetInfo = widgetService.toWidgetInfo(widgetType);
|
|
|
|
|
if (widgetInfo.type !== types.widgetType.static.value) {
|
|
|
|
|
var sizeX = widgetInfo.sizeX * 2;
|
|
|
|
|
var sizeY = widgetInfo.sizeY * 2;
|
|
|
|
|
var col = Math.floor(Math.max(0, (20 - sizeX) / 2));
|
|
|
|
|
var widget = {
|
|
|
|
|
isSystemType: isSystem,
|
|
|
|
|
bundleAlias: bundleAlias,
|
|
|
|
|
typeAlias: widgetInfo.alias,
|
|
|
|
|
type: widgetInfo.type,
|
|
|
|
|
title: widgetInfo.widgetName,
|
|
|
|
|
sizeX: sizeX,
|
|
|
|
|
sizeY: sizeY,
|
|
|
|
|
row: 0,
|
|
|
|
|
col: col,
|
|
|
|
|
config: angular.fromJson(widgetInfo.defaultConfig)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
widget.config.title = widgetInfo.widgetName;
|
|
|
|
|
widget.config.datasources = [datasource];
|
|
|
|
|
var length;
|
|
|
|
|
if (scope.attributeScope === types.latestTelemetry && widgetInfo.type !== types.widgetType.rpc.value) {
|
|
|
|
|
length = scope.widgetsListCache.push([widget]);
|
|
|
|
|
scope.widgetsList.push(length === 1 ? [widget] : []);
|
|
|
|
|
} else if (widgetInfo.type === types.widgetType.latest.value) {
|
|
|
|
|
length = scope.widgetsListCache.push([widget]);
|
|
|
|
|
scope.widgetsList.push(length === 1 ? [widget] : []);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
scope.widgetsLoaded = true;
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scope.exitWidgetMode = function() {
|
|
|
|
|
if (scope.widgetsBundleWatch) {
|
|
|
|
|
scope.widgetsBundleWatch();
|
|
|
|
|
scope.widgetsBundleWatch = null;
|
|
|
|
|
}
|
|
|
|
|
if (scope.widgetsIndexWatch) {
|
|
|
|
|
scope.widgetsIndexWatch();
|
|
|
|
|
scope.widgetsIndexWatch = null;
|
|
|
|
|
}
|
|
|
|
|
scope.selectedWidgetsBundleAlias = null;
|
|
|
|
|
scope.mode = 'default';
|
|
|
|
|
scope.getEntityAttributes(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scope.getServerTimeDiff = function() {
|
|
|
|
|
return dashboardService.getServerTimeDiff();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scope.addWidgetToDashboard = function($event) {
|
|
|
|
|
if (scope.mode === 'widget' && scope.widgetsListCache.length > 0) {
|
|
|
|
|
var widget = scope.widgetsListCache[scope.widgetsCarousel.index][0];
|
|
|
|
|
$event.stopPropagation();
|
|
|
|
|
$mdDialog.show({
|
|
|
|
|
controller: 'AddWidgetToDashboardDialogController',
|
|
|
|
|
controllerAs: 'vm',
|
|
|
|
|
templateUrl: addWidgetToDashboardDialogTemplate,
|
|
|
|
|
parent: angular.element($document[0].body),
|
|
|
|
|
locals: {entityId: scope.entityId, entityType: scope.entityType, entityName: scope.entityName, widget: angular.copy(widget)},
|
|
|
|
|
fullscreen: true,
|
|
|
|
|
targetEvent: $event
|
|
|
|
|
}).then(function () {
|
|
|
|
|
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scope.loading = function() {
|
|
|
|
|
return $rootScope.loading;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$compile(element.contents())(scope);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
restrict: "E",
|
|
|
|
|
link: linker,
|
|
|
|
|
scope: {
|
|
|
|
|
entityId: '=',
|
|
|
|
|
entityName: '=',
|
|
|
|
|
disableAttributeScopeSelection: '@?'
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|