bslinkedsetfieldrenderer.class.inc.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542
  1. <?php
  2. // Copyright (C) 2010-2016 Combodo SARL
  3. //
  4. // This file is part of iTop.
  5. //
  6. // iTop is free software; you can redistribute it and/or modify
  7. // it under the terms of the GNU Affero General Public License as published by
  8. // the Free Software Foundation, either version 3 of the License, or
  9. // (at your option) any later version.
  10. //
  11. // iTop is distributed in the hope that it will be useful,
  12. // but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. // GNU Affero General Public License for more details.
  15. //
  16. // You should have received a copy of the GNU Affero General Public License
  17. // along with iTop. If not, see <http://www.gnu.org/licenses/>
  18. namespace Combodo\iTop\Renderer\Bootstrap\FieldRenderer;
  19. use \utils;
  20. use \Dict;
  21. use \UserRights;
  22. use \InlineImage;
  23. use \DBObjectSet;
  24. use \MetaModel;
  25. use \Combodo\iTop\Renderer\FieldRenderer;
  26. use \Combodo\iTop\Renderer\RenderingOutput;
  27. use \Combodo\iTop\Form\Field\LinkedSetField;
  28. /**
  29. * Description of BsLinkedSetFieldRenderer
  30. *
  31. * @author Guillaume Lajarige <guillaume.lajarige@combodo.com>
  32. */
  33. class BsLinkedSetFieldRenderer extends FieldRenderer
  34. {
  35. /**
  36. * Returns a RenderingOutput for the FieldRenderer's Field
  37. *
  38. * @return \Combodo\iTop\Renderer\RenderingOutput
  39. */
  40. public function Render()
  41. {
  42. $oOutput = new RenderingOutput();
  43. $sFieldMandatoryClass = ($this->oField->GetMandatory()) ? 'form_mandatory' : '';
  44. // Vars to build the table
  45. $sAttributesToDisplayAsJson = json_encode($this->oField->GetAttributesToDisplay());
  46. $sAttCodesToDisplayAsJson = json_encode($this->oField->GetAttributesToDisplay(true));
  47. $aItems = array();
  48. $aItemIds = array();
  49. $this->PrepareItems($aItems, $aItemIds);
  50. $sItemsAsJson = json_encode($aItems);
  51. $sItemIdsAsJson = htmlentities(json_encode($aItemIds), ENT_QUOTES, 'UTF-8');
  52. if (!$this->oField->GetHidden())
  53. {
  54. // Rendering field
  55. $sCollapseTogglerVisibleClass = 'glyphicon-menu-right';
  56. $sCollapseTogglerHiddenClass = 'glyphicon-menu-down';
  57. $sCollapseTogglerId = 'form_linkedset_toggler_' . $this->oField->GetGlobalId();
  58. $sFieldWrapperId = 'form_linkedset_wrapper_' . $this->oField->GetGlobalId();
  59. $oOutput->AddHtml('<div class="form-group ' . $sFieldMandatoryClass . '">');
  60. if ($this->oField->GetLabel() !== '')
  61. {
  62. $oOutput->AddHtml('<label for="' . $this->oField->GetGlobalId() . '" class="control-label">')
  63. ->AddHtml($this->oField->GetLabel(), true)
  64. ->AddHtml('<a id="' . $sCollapseTogglerId . '" class="form_linkedset_toggler" data-toggle="collapse" href="#' . $sFieldWrapperId . '" aria-expanded="false" aria-controls="' . $sFieldWrapperId . '">')
  65. ->AddHtml('<span class="text">' . count($aItemIds) . '</span>')
  66. ->AddHtml('<span class="glyphicon ' . $sCollapseTogglerHiddenClass . '"></>')
  67. ->AddHtml('</a>')
  68. ->AddHtml('</label>');
  69. }
  70. $oOutput->AddHtml('<div class="help-block"></div>');
  71. // Rendering table
  72. // - Vars
  73. $sTableId = 'table_' . $this->oField->GetGlobalId();
  74. // - Output
  75. $oOutput->AddHtml(
  76. <<<EOF
  77. <div class="form_linkedset_wrapper collapse" id="{$sFieldWrapperId}">
  78. <div class="row">
  79. <div class="col-xs-12">
  80. <input type="hidden" id="{$this->oField->GetGlobalId()}" name="{$this->oField->GetId()}" value="{$sItemIdsAsJson}" />
  81. <table id="{$sTableId}" data-field-id="{$this->oField->GetId()}" class="table table-striped table-bordered responsive" cellspacing="0" width="100%">
  82. <tbody>
  83. </tbody>
  84. </table>
  85. </div>
  86. </div>
  87. EOF
  88. );
  89. // Rendering table widget
  90. // - Vars
  91. $sEmptyTableLabel = htmlentities(Dict::S(($this->oField->GetReadOnly()) ? 'Portal:Datatables:Language:EmptyTable' : 'UI:Message:EmptyList:UseAdd'), ENT_QUOTES, 'UTF-8');
  92. $sLabelGeneralCheckbox = htmlentities(Dict::S('Core:BulkExport:CheckAll') . ' / ' . Dict::S('Core:BulkExport:UncheckAll'), ENT_QUOTES, 'UTF-8');
  93. $sSelectionOptionHtml = ($this->oField->GetReadOnly()) ? 'false' : '{"style": "multi"}';
  94. $sSelectionInputGlobalHtml = ($this->oField->GetReadOnly()) ? '' : '<span class="row_input"><input type="checkbox" id="' . $this->oField->GetGlobalId() . '_check_all" name="' . $this->oField->GetGlobalId() . '_check_all" title="' . $sLabelGeneralCheckbox . '" /></span>';
  95. $sSelectionInputHtml = ($this->oField->GetReadOnly()) ? '' : '<span class="row_input"><input type="checkbox" name="' . $this->oField->GetGlobalId() . '" /></span>';
  96. // - Output
  97. $oOutput->AddJs(
  98. <<<EOF
  99. // Collapse handlers
  100. // - Collapsing by default to optimize form space
  101. // It would be better to be able to construct the widget as collapsed, but in this ase, datatables thinks the container is very small and therefore renders the table as if it was in microbox.
  102. $('#{$sFieldWrapperId}').collapse({toggle: false});
  103. // - Change toggle icon class
  104. $('#{$sFieldWrapperId}').on('shown.bs.collapse', function(){
  105. // Creating the table if null (first expand). If we create it on start, it will be displayed as if it was in a micro screen due to the div being "display: none;"
  106. if(oTable_{$this->oField->GetGlobalId()} === undefined)
  107. {
  108. buildTable_{$this->oField->GetGlobalId()}();
  109. }
  110. $('#{$sCollapseTogglerId} > span.glyphicon').removeClass('{$sCollapseTogglerHiddenClass}').addClass('{$sCollapseTogglerVisibleClass}');
  111. })
  112. .on('hidden.bs.collapse', function(){
  113. $('#{$sCollapseTogglerId} > span.glyphicon').removeClass('{$sCollapseTogglerVisibleClass}').addClass('{$sCollapseTogglerHiddenClass}');
  114. });
  115. // Places a loader in the empty datatables
  116. $('#{$sTableId} > tbody').html('<tr><td class="datatables_overlay" colspan="100">' + $('#page_overlay').html() + '</td></tr>');
  117. // Prepares data for datatables
  118. var oColumnProperties_{$this->oField->GetGlobalId()} = {$sAttributesToDisplayAsJson};
  119. var oRawDatas_{$this->oField->GetGlobalId()} = {$sItemsAsJson};
  120. var oTable_{$this->oField->GetGlobalId()};
  121. var oSelectedItems_{$this->oField->GetGlobalId()} = {};
  122. var getColumnsDefinition_{$this->oField->GetGlobalId()} = function()
  123. {
  124. var aColumnsDefinition = [];
  125. aColumnsDefinition.push({
  126. "width": "auto",
  127. "searchable": false,
  128. "sortable": false,
  129. "title": '{$sSelectionInputGlobalHtml}',
  130. "type": "html",
  131. "data": "",
  132. "render": function(data, type, row){ return '{$sSelectionInputHtml}'; }
  133. });
  134. for(sKey in oColumnProperties_{$this->oField->GetGlobalId()})
  135. {
  136. // Level main column
  137. aColumnsDefinition.push({
  138. "width": "auto",
  139. "searchable": true,
  140. "sortable": true,
  141. "title": oColumnProperties_{$this->oField->GetGlobalId()}[sKey],
  142. "defaultContent": "",
  143. "type": "html",
  144. "data": "attributes."+sKey+".att_code",
  145. "render": function(data, type, row){
  146. var cellElem;
  147. // Preparing the cell data
  148. if(row.attributes[data].url !== undefined)
  149. {
  150. cellElem = $('<a></a>');
  151. cellElem.attr('target', '_blank').attr('href', row.attributes[data].url);
  152. }
  153. else
  154. {
  155. cellElem = $('<span></span>');
  156. }
  157. cellElem.attr('data-object-id', row.id).html('<span>' + row.attributes[data].value + '</span>');
  158. return cellElem.prop('outerHTML');
  159. },
  160. });
  161. }
  162. return aColumnsDefinition;
  163. };
  164. // Helper to build the datatable
  165. // Note : Those options should be externalized in an library so we can use them on any DataTables for the portal.
  166. // We would just have to override / complete the necessary elements
  167. var buildTable_{$this->oField->GetGlobalId()} = function()
  168. {
  169. // Instanciates datatables
  170. oTable_{$this->oField->GetGlobalId()} = $('#{$sTableId}').DataTable({
  171. "language": {
  172. "emptyTable": "{$sEmptyTableLabel}"
  173. },
  174. "displayLength": -1,
  175. "scrollY": "300px",
  176. "scrollCollapse": true,
  177. "order": [[1, "asc"]],
  178. "dom": 't',
  179. "columns": getColumnsDefinition_{$this->oField->GetGlobalId()}(),
  180. "select": {$sSelectionOptionHtml},
  181. "rowId": "id",
  182. "data": oRawDatas_{$this->oField->GetGlobalId()},
  183. });
  184. // Handles items selection/deselection
  185. // - Directly on the table
  186. oTable_{$this->oField->GetGlobalId()}.off('select').on('select', function(oEvent, dt, type, indexes){
  187. var aData = oTable_{$this->oField->GetGlobalId()}.rows(indexes).data().toArray();
  188. // Checking input
  189. $('#{$sTableId} tbody tr[role="row"].selected td:first-child input').prop('checked', true);
  190. // Saving values in temp array
  191. for(var i in aData)
  192. {
  193. var iItemId = aData[i].id;
  194. if(!(iItemId in oSelectedItems_{$this->oField->GetGlobalId()}))
  195. {
  196. oSelectedItems_{$this->oField->GetGlobalId()}[iItemId] = aData[i].name;
  197. }
  198. }
  199. // Updating remove button
  200. updateRemoveButtonState_{$this->oField->GetGlobalId()}();
  201. });
  202. oTable_{$this->oField->GetGlobalId()}.off('deselect').on('deselect', function(oEvent, dt, type, indexes){
  203. var aData = oTable_{$this->oField->GetGlobalId()}.rows(indexes).data().toArray();
  204. // Checking input
  205. $('#{$sTableId} tbody tr[role="row"]:not(.selected) td:first-child input').prop('checked', false);
  206. // Saving values in temp array
  207. for(var i in aData)
  208. {
  209. var iItemId = aData[i].id;
  210. if(iItemId in oSelectedItems_{$this->oField->GetGlobalId()})
  211. {
  212. delete oSelectedItems_{$this->oField->GetGlobalId()}[iItemId];
  213. }
  214. }
  215. // Unchecking global checkbox
  216. $('#{$this->oField->GetGlobalId()}_check_all').prop('checked', false);
  217. // Updating remove button
  218. updateRemoveButtonState_{$this->oField->GetGlobalId()}();
  219. });
  220. // - From the global button
  221. $('#{$this->oField->GetGlobalId()}_check_all').off('click').on('click', function(oEvent){
  222. if($(this).prop('checked'))
  223. {
  224. oTable_{$this->oField->GetGlobalId()}.rows().select();
  225. }
  226. else
  227. {
  228. oTable_{$this->oField->GetGlobalId()}.rows().deselect();
  229. }
  230. updateRemoveButtonState_{$this->oField->GetGlobalId()}();
  231. });
  232. };
  233. EOF
  234. );
  235. // Attaching JS widget
  236. $sObjectInformationsUrl = $this->oField->GetInformationEndpoint();
  237. $oOutput->AddJs(
  238. <<<EOF
  239. $("[data-field-id='{$this->oField->GetId()}'][data-form-path='{$this->oField->GetFormPath()}']").portal_form_field({
  240. 'validators': {$this->GetValidatorsAsJson()},
  241. 'get_current_value_callback': function(me, oEvent, oData){
  242. var value = null;
  243. // Retrieving JSON value as a string and not an object
  244. //
  245. // Note : The value is passed as a string instead of an array because the attribute would not be included in the posted data when empty.
  246. // Which was an issue when deleting all objects from linkedset
  247. //
  248. // Old code : value = JSON.parse(me.element.find('#{$this->oField->GetGlobalId()}').val());
  249. value = me.element.find('#{$this->oField->GetGlobalId()}').val();
  250. return value;
  251. },
  252. 'set_current_value_callback': function(me, oEvent, oData){
  253. // When we have data (meaning that we picked objects from search)
  254. if(oData !== undefined && Object.keys(oData.values).length > 0)
  255. {
  256. // Showing loader while retrieving informations
  257. $('#page_overlay').fadeIn(200);
  258. // Retrieving new rows ids
  259. var aObjectIds = Object.keys(oData.values);
  260. // Retrieving rows informations so we can add them
  261. $.post(
  262. '{$sObjectInformationsUrl}',
  263. {
  264. sObjectClass: '{$this->oField->GetTargetClass()}',
  265. aObjectIds: aObjectIds,
  266. aObjectAttCodes: $sAttCodesToDisplayAsJson
  267. },
  268. function(oData){
  269. // Updating datatables
  270. if(oData.items !== undefined)
  271. {
  272. for(var i in oData.items)
  273. {
  274. // Adding item to table only if it's not already there
  275. if($('#{$sTableId} tr#' + oData.items[i].id + '[role="row"]').length === 0)
  276. {
  277. // Making id negative in order to recognize it when persisting
  278. oData.items[i].id = -1 * parseInt(oData.items[i].id);
  279. oTable_{$this->oField->GetGlobalId()}.row.add(oData.items[i]);
  280. }
  281. }
  282. oTable_{$this->oField->GetGlobalId()}.draw();
  283. }
  284. }
  285. )
  286. .done(function(oData){
  287. // Updating hidden field
  288. var aData = oTable_{$this->oField->GetGlobalId()}.rows().data().toArray();
  289. var aObjectIds = [];
  290. for(var i in aData)
  291. {
  292. aObjectIds.push({id: aData[i].id});
  293. }
  294. $('#{$this->oField->GetGlobalId()}').val(JSON.stringify(aObjectIds));
  295. // Updating items count
  296. updateItemCount();
  297. })
  298. .always(function(oData){
  299. // Hiding loader
  300. $('#page_overlay').fadeOut(200);
  301. });
  302. }
  303. // We come from a button
  304. else
  305. {
  306. // Updating hidden field
  307. var aData = oTable_{$this->oField->GetGlobalId()}.rows().data().toArray();
  308. var aObjectIds = [];
  309. for(var i in aData)
  310. {
  311. aObjectIds.push({id: aData[i].id});
  312. }
  313. $('#{$this->oField->GetGlobalId()}').val(JSON.stringify(aObjectIds));
  314. // Updating items count
  315. updateItemCount();
  316. }
  317. }
  318. });
  319. EOF
  320. );
  321. // Additional features if in edition mode
  322. if (!$this->oField->GetReadOnly())
  323. {
  324. // Rendering table
  325. // - Vars
  326. $sButtonRemoveId = 'btn_remove_' . $this->oField->GetGlobalId();
  327. $sButtonAddId = 'btn_add_' . $this->oField->GetGlobalId();
  328. $sLabelRemove = Dict::S('UI:Button:Remove');
  329. $sLabelAdd = Dict::S('UI:Button:AddObject');
  330. // - Output
  331. $oOutput->AddHtml(
  332. <<<EOF
  333. <div class="row">
  334. <div class="col-xs-12">
  335. <div class="btn-group" role="group">
  336. <button type="button" class="btn btn-sm btn-danger" id="{$sButtonRemoveId}" title="{$sLabelRemove}" disabled><span class="glyphicon glyphicon-minus"></span></button>
  337. <button type="button" class="btn btn-sm btn-default" id="{$sButtonAddId}" title="{$sLabelAdd}"><span class="glyphicon glyphicon-plus"></span></button>
  338. </div>
  339. </div>
  340. </div>
  341. EOF
  342. );
  343. // Rendering table widget
  344. // - Vars
  345. $sAddButtonEndpoint = str_replace('-sMode-', 'from-attribute', $this->oField->GetSearchEndpoint());
  346. // - Output
  347. $oOutput->AddJs(
  348. <<<EOF
  349. // Handles items selection/deselection
  350. // - Remove button state handler
  351. var updateRemoveButtonState_{$this->oField->GetGlobalId()} = function()
  352. {
  353. var bIsDisabled = (Object.keys(oSelectedItems_{$this->oField->GetGlobalId()}).length == 0);
  354. $('#{$sButtonRemoveId}').prop('disabled', bIsDisabled);
  355. };
  356. // - Item count state handler
  357. var updateItemCount = function()
  358. {
  359. console.log('in fct');
  360. console.log(oTable_{$this->oField->GetGlobalId()}.rows().count());
  361. $('#{$sCollapseTogglerId} > .text').text( oTable_{$this->oField->GetGlobalId()}.rows().count() );
  362. };
  363. // Handles items remove/add
  364. $('#{$sButtonRemoveId}').off('click').on('click', function(){
  365. // Removing items from table
  366. oTable_{$this->oField->GetGlobalId()}.rows({selected: true}).remove().draw();
  367. // Resetting selected items
  368. oSelectedItems_{$this->oField->GetGlobalId()} = {};
  369. // Updating form value
  370. $("[data-field-id='{$this->oField->GetId()}'][data-form-path='{$this->oField->GetFormPath()}']").triggerHandler('set_current_value');
  371. // Updating remove button
  372. updateRemoveButtonState_{$this->oField->GetGlobalId()}();
  373. });
  374. $('#{$sButtonAddId}').off('click').on('click', function(){
  375. // Creating a new modal
  376. var oModalElem;
  377. if($('.modal[data-source-element="{$sButtonAddId}"]').length === 0)
  378. {
  379. oModalElem = $('#modal-for-all').clone();
  380. oModalElem.attr('id', '').attr('data-source-element', '{$sButtonAddId}').appendTo('body');
  381. }
  382. else
  383. {
  384. oModalElem = $('.modal[data-source-element="{$sButtonAddId}"]').first();
  385. }
  386. // Resizing to small modal
  387. oModalElem.find('.modal-dialog').removeClass('modal-sm').addClass('modal-lg');
  388. // Loading content
  389. oModalElem.find('.modal-content').html($('#page_overlay .overlay_content').html());
  390. oModalElem.find('.modal-content').load(
  391. '{$sAddButtonEndpoint}',
  392. {
  393. sFormPath: '{$this->oField->GetFormPath()}',
  394. sFieldId: '{$this->oField->GetId()}'
  395. }
  396. );
  397. oModalElem.modal('show');
  398. });
  399. EOF
  400. );
  401. }
  402. }
  403. // ... and in hidden mode
  404. else
  405. {
  406. $oOutput->AddHtml('<input type="hidden" id="' . $this->oField->GetGlobalId() . '" name="' . $this->oField->GetId() . '" value="' . $sItemIdsAsJson . '" />');
  407. }
  408. // End of table rendering
  409. $oOutput->AddHtml('</div>');
  410. $oOutput->AddHtml('</div>');
  411. return $oOutput;
  412. }
  413. protected function PrepareItems(&$aItems, &$aItemIds)
  414. {
  415. $oValueSet = $this->oField->GetCurrentValue();
  416. $oValueSet->OptimizeColumnLoad(array($this->oField->GetTargetClass() => $this->oField->GetAttributesToDisplay(true)));
  417. while ($oItem = $oValueSet->Fetch())
  418. {
  419. $aItemProperties = array(
  420. 'id' => $oItem->GetKey(),
  421. 'name' => $oItem->GetName(),
  422. 'attributes' => array()
  423. );
  424. // In case of indirect linked set, we must retrieve the remote object
  425. if ($this->oField->IsIndirect())
  426. {
  427. $oRemoteItem = MetaModel::GetObject($this->oField->GetTargetClass(), $oItem->Get($this->oField->GetExtKeyToRemote()));
  428. }
  429. else
  430. {
  431. $oRemoteItem = $oItem;
  432. }
  433. foreach ($this->oField->GetAttributesToDisplay(true) as $sAttCode)
  434. {
  435. if ($sAttCode !== 'id')
  436. {
  437. $aAttProperties = array(
  438. 'att_code' => $sAttCode
  439. );
  440. $oAttDef = MetaModel::GetAttributeDef($this->oField->GetTargetClass(), $sAttCode);
  441. if ($oAttDef->IsExternalKey())
  442. {
  443. $aAttProperties['value'] = $oRemoteItem->Get($sAttCode . '_friendlyname');
  444. }
  445. else
  446. {
  447. $aAttProperties['value'] = $oAttDef->GetValueLabel($oRemoteItem->Get($sAttCode));
  448. }
  449. $aItemProperties['attributes'][$sAttCode] = $aAttProperties;
  450. }
  451. }
  452. $aItems[] = $aItemProperties;
  453. $aItemIds[] = array('id' => $oItem->GetKey());
  454. }
  455. }
  456. /**
  457. * Renders an regular search button
  458. *
  459. * @param RenderingOutput $oOutput
  460. */
  461. protected function RenderRegularSearch(RenderingOutput &$oOutput)
  462. {
  463. $sSearchButtonId = 's_rg_' . $this->oField->GetGlobalId();
  464. $sEndpoint = str_replace('-sMode-', 'from-attribute', $this->oField->GetSearchEndpoint());
  465. $oOutput->AddHtml('<div class="col-xs-2 col-lg-1">');
  466. $oOutput->AddHtml('<button type="button" class="btn btn-default" id="' . $sSearchButtonId . '">S</button>');
  467. $oOutput->AddHtml('</div>');
  468. $oOutput->AddJs(
  469. <<<EOF
  470. $('#{$sSearchButtonId}').off('click').on('click', function(){
  471. // Creating a new modal
  472. var oModalElem;
  473. if($('.modal[data-source-element="{$sSearchButtonId}"]').length === 0)
  474. {
  475. oModalElem = $('#modal-for-all').clone();
  476. oModalElem.attr('id', '').attr('data-source-element', '{$sSearchButtonId}').appendTo('body');
  477. }
  478. else
  479. {
  480. oModalElem = $('.modal[data-source-element="{$sSearchButtonId}"]').first();
  481. }
  482. // Resizing to small modal
  483. oModalElem.find('.modal-dialog').removeClass('modal-sm').addClass('modal-lg');
  484. // Loading content
  485. oModalElem.find('.modal-content').html($('#page_overlay .overlay_content').html());
  486. oModalElem.find('.modal-content').load(
  487. '{$sEndpoint}',
  488. {
  489. sFormPath: '{$this->oField->GetFormPath()}',
  490. sFieldId: '{$this->oField->GetId()}'
  491. }
  492. );
  493. oModalElem.modal('show');
  494. });
  495. EOF
  496. );
  497. }
  498. }