. */ namespace Xibo\Listener; use Carbon\Carbon; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Xibo\Entity\DataSet; use Xibo\Event\DataSetDataRequestEvent; use Xibo\Event\DataSetDataTypeRequestEvent; use Xibo\Event\DataSetModifiedDtRequestEvent; use Xibo\Factory\DataSetFactory; use Xibo\Factory\DisplayFactory; use Xibo\Service\ConfigServiceInterface; use Xibo\Storage\StorageServiceInterface; use Xibo\Support\Exception\NotFoundException; use Xibo\Widget\Definition\DataType; use Xibo\Widget\Provider\DataProviderInterface; /** * Listens to requests for data from DataSets. */ class DataSetDataProviderListener { use ListenerLoggerTrait; /** @var \Xibo\Storage\StorageServiceInterface */ private $store; /** @var \Xibo\Service\ConfigServiceInterface */ private $config; /** @var \Xibo\Factory\DataSetFactory */ private $dataSetFactory; /** @var \Xibo\Factory\DisplayFactory */ private $displayFactory; public function __construct( StorageServiceInterface $store, ConfigServiceInterface $config, DataSetFactory $dataSetFactory, DisplayFactory $displayFactory ) { $this->store = $store; $this->config = $config; $this->dataSetFactory = $dataSetFactory; $this->displayFactory = $displayFactory; } public function registerWithDispatcher(EventDispatcherInterface $dispatcher): DataSetDataProviderListener { $dispatcher->addListener(DataSetDataRequestEvent::$NAME, [$this, 'onDataRequest']); $dispatcher->addListener(DataSetDataTypeRequestEvent::$NAME, [$this, 'onDataTypeRequest']); $dispatcher->addListener(DataSetModifiedDtRequestEvent::$NAME, [$this, 'onModifiedDtRequest']); return $this; } public function onDataRequest(DataSetDataRequestEvent $event) { $this->getLogger()->debug('onDataRequest: data source is ' . $event->getDataProvider()->getDataSource()); $dataProvider = $event->getDataProvider(); // We must have a dataSetId configured. $dataSetId = $dataProvider->getProperty('dataSetId', 0); if (empty($dataSetId)) { $this->getLogger()->debug('onDataRequest: no dataSetId.'); return; } // Get this dataset try { $dataSet = $this->dataSetFactory->getById($dataSetId); } catch (NotFoundException $notFoundException) { $this->getLogger()->error('onDataRequest: dataSetId ' . $dataSetId . ' not found.'); return; } $this->getData($dataSet, $dataProvider); // Cache timeout $dataProvider->setCacheTtl($dataProvider->getProperty('updateInterval', 60) * 60); } /** * @param \Xibo\Event\DataSetDataTypeRequestEvent $event * @return void */ public function onDataTypeRequest(DataSetDataTypeRequestEvent $event) { // We must have a dataSetId configured. $dataSetId = $event->getDataSetId(); if (empty($dataSetId)) { $this->getLogger()->debug('onDataTypeRequest: no dataSetId.'); return; } $this->getLogger()->debug('onDataTypeRequest: with dataSetId: ' . $dataSetId); // Get this dataset try { $dataSet = $this->dataSetFactory->getById($dataSetId); // Create a new DataType for this DataSet $dataType = new DataType(); $dataType->id = 'dataset'; $dataType->name = $dataSet->dataSet; // Get the columns for this dataset and return a list of them foreach ($dataSet->getColumn() as $column) { $dataType->addField( $column->heading . '|' . $column->dataSetColumnId, $column->heading, $column->dataType ); } $event->setDataType($dataType); } catch (NotFoundException $notFoundException) { $this->getLogger()->error('onDataTypeRequest: dataSetId ' . $dataSetId . ' not found.'); return; } } public function onModifiedDtRequest(DataSetModifiedDtRequestEvent $event) { $this->getLogger()->debug('onModifiedDtRequest: get modifiedDt with dataSetId: ' . $event->getDataSetId()); try { $dataSet = $this->dataSetFactory->getById($event->getDataSetId()); $event->setModifiedDt(Carbon::createFromTimestamp($dataSet->lastDataEdit)); // Remote dataSets are kept "active" by required files $dataSet->setActive(); } catch (NotFoundException $notFoundException) { $this->getLogger()->error('onModifiedDtRequest: dataSetId ' . $event->getDataSetId() . ' not found.'); } } private function getData(DataSet $dataSet, DataProviderInterface $dataProvider): void { // Load the dataSet $dataSet->load(); // Columns // Build a list of column mappings we will make available as metadata $mappings = []; $columnIds = $dataProvider->getProperty('columns'); $columnIds = empty($columnIds) ? null : explode(',', $columnIds); $this->getLogger()->debug('getData: loaded dataSetId ' . $dataSet->dataSetId . ', there are ' . count($dataSet->columns) . '. We have selected ' . ($columnIds !== null ? count($columnIds) : 'all') . ' of them'); foreach ($dataSet->columns as $column) { if ($columnIds === null || in_array($column->dataSetColumnId, $columnIds)) { $mappings[] = [ 'dataSetColumnId' => $column->dataSetColumnId, 'heading' => $column->heading, 'dataTypeId' => $column->dataTypeId ]; } } $this->getLogger()->debug('getData: resolved ' . count($mappings) . ' column mappings'); // Build filter, order and limit parameters to pass to the DataSet entity // Ordering $ordering = ''; if ($dataProvider->getProperty('useOrderingClause', 1) == 1) { $ordering = $dataProvider->getProperty('ordering'); } else { // Build an order string foreach (json_decode($dataProvider->getProperty('orderClauses', '[]'), true) as $clause) { $ordering .= $clause['orderClause'] . ' ' . $clause['orderClauseDirection'] . ','; } $ordering = rtrim($ordering, ','); } // Build a filter to pass to the dataset $filter = [ 'filter' => $this->buildFilterClause($dataProvider), 'order' => $ordering, 'displayId' => $dataProvider->getDisplayId(), ]; // limits? $upperLimit = $dataProvider->getProperty('upperLimit', 0); $lowerLimit = $dataProvider->getProperty('lowerLimit', 0); if ($lowerLimit !== 0 || $upperLimit !== 0) { // Start should be the lower limit // Size should be the distance between upper and lower $filter['start'] = $lowerLimit; $filter['size'] = $upperLimit - $lowerLimit; $this->getLogger()->debug('getData: applied limits, start: ' . $filter['start'] . ', size: ' . $filter['size']); } // Expiry time for any images $expires = Carbon::now() ->addSeconds($dataProvider->getProperty('updateInterval', 3600) * 60) ->format('U'); try { $this->setTimezone($dataProvider); $dataSetResults = $dataSet->getData($filter); $this->getLogger()->debug('getData: finished getting data. There are ' . count($dataSetResults) . ' records returned'); foreach ($dataSetResults as $row) { // Add an item containing the columns we have selected $item = []; foreach ($mappings as $mapping) { // This column is selected $cellValue = $row[$mapping['heading']] ?? null; if ($mapping['dataTypeId'] === 4) { // Grab the external image $item[$mapping['heading']] = $dataProvider->addImage( 'dataset_' . md5($dataSet->dataSetId . $mapping['dataSetColumnId'] . $cellValue), str_replace(' ', '%20', htmlspecialchars_decode($cellValue)), $expires ); } else if ($mapping['dataTypeId'] === 5) { // Library Image $this->getLogger()->debug('getData: Library media reference found: ' . $cellValue); // The content is the ID of the image try { $item[$mapping['heading']] = $dataProvider->addLibraryFile(intval($cellValue)); } catch (NotFoundException $notFoundException) { $this->getLogger()->error('getData: Invalid library media reference: ' . $cellValue); $item[$mapping['heading']] = ''; } } else { // Just a normal column $item[$mapping['heading']] = $cellValue; } } $dataProvider->addItem($item); } // Add the mapping we've generated to the metadata $dataProvider->addOrUpdateMeta('mapping', $mappings); $dataProvider->setIsHandled(); } catch (\Exception $exception) { $this->getLogger()->debug('onDataRequest: ' . $exception->getTraceAsString()); $this->getLogger()->error('onDataRequest: unable to get data for dataSetId ' . $dataSet->dataSetId . ' e: ' . $exception->getMessage()); $dataProvider->addError(__('DataSet Invalid')); } } private function buildFilterClause(DataProviderInterface $dataProvider): ?string { $filter = ''; if ($dataProvider->getProperty('useFilteringClause', 1) == 1) { $filter = $dataProvider->getProperty('filter'); } else { // Build $i = 0; foreach (json_decode($dataProvider->getProperty('filterClauses', '[]'), true) as $clause) { $i++; switch ($clause['filterClauseCriteria']) { case 'starts-with': $criteria = 'LIKE \'' . $clause['filterClauseValue'] . '%\''; break; case 'ends-with': $criteria = 'LIKE \'%' . $clause['filterClauseValue'] . '\''; break; case 'contains': $criteria = 'LIKE \'%' . $clause['filterClauseValue'] . '%\''; break; case 'equals': $criteria = '= \'' . $clause['filterClauseValue'] . '\''; break; case 'not-contains': $criteria = 'NOT LIKE \'%' . $clause['filterClauseValue'] . '%\''; break; case 'not-starts-with': $criteria = 'NOT LIKE \'' . $clause['filterClauseValue'] . '%\''; break; case 'not-ends-with': $criteria = 'NOT LIKE \'%' . $clause['filterClauseValue'] . '\''; break; case 'not-equals': $criteria = '<> \'' . $clause['filterClauseValue'] . '\''; break; case 'greater-than': $criteria = '> \'' . $clause['filterClauseValue'] . '\''; break; case 'less-than': $criteria = '< \'' . $clause['filterClauseValue'] . '\''; break; default: continue 2; } if ($i > 1) { $filter .= ' ' . $clause['filterClauseOperator'] . ' '; } $filter .= $clause['filterClause'] . ' ' . $criteria; } } return $filter; } /** * @param \Xibo\Widget\Provider\DataProviderInterface $dataProvider * @return void * @throws \Xibo\Support\Exception\NotFoundException */ private function setTimezone(DataProviderInterface $dataProvider) { // Set the timezone for SQL $dateNow = Carbon::now(); if ($dataProvider->getDisplayId() != 0) { $display = $this->displayFactory->getById($dataProvider->getDisplayId()); $timeZone = $display->getSetting('displayTimeZone', ''); $timeZone = ($timeZone == '') ? $this->config->getSetting('defaultTimezone') : $timeZone; $dateNow->timezone($timeZone); $this->logger->debug(sprintf( 'Display Timezone Resolved: %s. Time: %s.', $timeZone, $dateNow->toDateTimeString() )); } // Run this command on a new connection so that we do not interfere with any other queries on this connection. $this->store->setTimeZone($dateNow->format('P'), 'dataset'); $this->getLogger()->debug('setTimezone: finished setting timezone'); } }