3
* Xibo - Digital Signage - http://www.xibo.org.uk
4
* Copyright (C) 2006-2015 Daniel Garner
6
* This file is part of Xibo.
8
* Xibo is free software: you can redistribute it and/or modify
9
* it under the terms of the GNU Affero General Public License as published by
10
* the Free Software Foundation, either version 3 of the License, or
13
* Xibo is distributed in the hope that it will be useful,
14
* but WITHOUT ANY WARRANTY; without even the implied warranty of
15
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16
* GNU Affero General Public License for more details.
18
* You should have received a copy of the GNU Affero General Public License
19
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
21
namespace Xibo\Widget;
23
use GuzzleHttp\Client;
24
use GuzzleHttp\Exception\RequestException;
25
use PicoFeed\Config\Config;
26
use PicoFeed\Logging\Logger;
27
use PicoFeed\Parser\Item;
28
use PicoFeed\PicoFeedException;
29
use PicoFeed\Reader\Reader;
30
use Respect\Validation\Validator as v;
31
use Stash\Invalidation;
32
use Xibo\Controller\Library;
33
use Xibo\Entity\DataSetColumn;
34
use Xibo\Exception\NotFoundException;
35
use Xibo\Exception\XiboException;
36
use Xibo\Service\LogService;
39
class Ticker extends ModuleWidget
44
public function installFiles()
46
$this->mediaFactory->createModuleSystemFile(PROJECT_ROOT . '/modules/vendor/jquery-1.11.1.min.js')->save();
47
$this->mediaFactory->createModuleSystemFile(PROJECT_ROOT . '/modules/vendor/moment.js')->save();
48
$this->mediaFactory->createModuleSystemFile(PROJECT_ROOT . '/modules/vendor/jquery.marquee.min.js')->save();
49
$this->mediaFactory->createModuleSystemFile(PROJECT_ROOT . '/modules/vendor/jquery-cycle-2.1.6.min.js')->save();
50
$this->mediaFactory->createModuleSystemFile(PROJECT_ROOT . '/modules/xibo-layout-scaler.js')->save();
51
$this->mediaFactory->createModuleSystemFile(PROJECT_ROOT . '/modules/xibo-text-render.js')->save();
52
$this->mediaFactory->createModuleSystemFile(PROJECT_ROOT . '/modules/xibo-image-render.js')->save();
58
public function layoutDesignerJavaScript()
60
// We use the same javascript as the data set view designer
61
return 'datasetview-designer-javascript';
66
* @return array[DataSet]
68
public function dataSets()
70
return $this->dataSetFactory->query();
74
* Get Data Set Columns
75
* @return array[DataSetColumn]
77
public function dataSetColumns()
79
if ($this->getOption('dataSetId') == 0)
80
throw new \InvalidArgumentException(__('DataSet not selected'));
82
return $this->dataSetColumnFactory->getByDataSetId($this->getOption('dataSetId'));
86
* Get the Order Clause
89
public function getOrderClause()
91
return json_decode($this->getOption('orderClauses', "[]"), true);
95
* Get the Filter Clause
98
public function getFilterClause()
100
return json_decode($this->getOption('filterClauses', "[]"), true);
104
* Get Extra content for the form
107
public function getExtra()
109
if ($this->getOption('sourceId') == 2) {
111
'templates' => $this->templatesAvailable(),
112
'orderClause' => $this->getOrderClause(),
113
'filterClause' => $this->getFilterClause(),
114
'columns' => $this->dataSetColumns(),
115
'dataSet' => ($this->getOption('dataSetId', 0) != 0) ? $this->dataSetFactory->getById($this->getOption('dataSetId')) : null
119
'templates' => $this->templatesAvailable(),
124
public function validate()
126
// Must have a duration
127
if ($this->getUseDuration() == 1 && $this->getDuration() == 0)
128
throw new \InvalidArgumentException(__('Please enter a duration'));
130
$sourceId = $this->getOption('sourceId');
132
if ($sourceId == 1) {
135
if (!v::url()->notEmpty()->validate(urldecode($this->getOption('uri'))))
136
throw new \InvalidArgumentException(__('Please enter a Link for this Ticker'));
138
} else if ($sourceId == 2) {
140
// Validate Data Set Selected
141
if ($this->getOption('dataSetId') == 0)
142
throw new \InvalidArgumentException(__('Please select a DataSet'));
144
// Check we have permission to use this DataSetId
145
if (!$this->getUser()->checkViewable($this->dataSetFactory->getById($this->getOption('dataSetId'))))
146
throw new \InvalidArgumentException(__('You do not have permission to use that dataset'));
148
if ($this->widget->widgetId != 0) {
149
// Some extra edit validation
150
// Make sure we havent entered a silly value in the filter
151
if (strstr($this->getOption('filter'), 'DESC'))
152
throw new \InvalidArgumentException(__('Cannot user ordering criteria in the Filter Clause'));
154
if (!is_numeric($this->getOption('upperLimit')) || !is_numeric($this->getOption('lowerLimit')))
155
throw new \InvalidArgumentException(__('Limits must be numbers'));
157
if ($this->getOption('upperLimit') < 0 || $this->getOption('lowerLimit') < 0)
158
throw new \InvalidArgumentException(__('Limits cannot be lower than 0'));
160
// Check the bounds of the limits
161
if ($this->getOption('upperLimit') < $this->getOption('lowerLimit'))
162
throw new \InvalidArgumentException(__('Upper limit must be higher than lower limit'));
166
// Only supported two source types at the moment
167
throw new \InvalidArgumentException(__('Unknown Source Type'));
170
if ($this->widget->widgetId != 0) {
171
// Make sure we have a number in here
172
if (!v::numeric()->validate($this->getOption('numItems', 0)))
173
throw new \InvalidArgumentException(__('The value in Number of Items must be numeric.'));
175
if (!v::intType()->min(0)->validate($this->getOption('updateInterval')))
176
throw new \InvalidArgumentException(__('Update Interval must be greater than or equal to 0'));
181
* Adds a Ticker Widget
183
* path="/playlist/widget/ticker/{playlistId}",
184
* operationId="WidgetTickerAdd",
186
* summary="Add a ticker Widget",
187
* description="Add a new ticker Widget to the specified playlist",
191
* description="The playlist ID to add a Widget to",
198
* description="Optional Widget Name",
205
* description="The Widget Duration",
210
* name="useDuration",
212
* description="(0, 1) Select 1 only if you will provide duration parameter as well",
219
* description="Add only - 1 for rss feed, 2 for dataset",
226
* description="For sourceId=1, the link for the rss feed",
233
* description="For sourceId=2, Create ticker Widget using provided dataSetId of an existing dataSet",
238
* name="updateInterval",
240
* description="EDIT Only - Update interval in minutes",
247
* description="Edit only - Effect that will be used to transitions between items, available options: fade, fadeout, scrollVert, scollHorz, flipVert, flipHorz, shuffle, tileSlide, tileBlind, marqueeUp, marqueeDown, marqueeRight, marqueeLeft",
254
* description="Edit only - The transition speed of the selected effect in milliseconds (1000 = normal) or the Marquee speed in a low to high scale (normal = 1)",
261
* description="EDIT Only and SourceId=1 - Copyright information to display as the last item in this feed. can be styled with the #copyright CSS selector",
268
* description="EDIT Only and SourceId=1 - The number of RSS items you want to display",
273
* name="takeItemsFrom",
275
* description="EDIT Only and SourceId=1 - Take the items form the beginning or the end of the list, available options: start, end",
280
* name="durationIsPerItem",
282
* description="A flag (0, 1), The duration specified is per item, otherwise it is per feed",
287
* name="itemsSideBySide",
289
* description="A flag (0, 1), Should items be shown side by side",
296
* description="EDIT Only, SourceId=2 - Upper low limit for this dataSet, 0 for nor limit",
303
* description="EDIT Only, SourceId=2 - Lower low limit for this dataSet, 0 for nor limit",
308
* name="itemsPerPage",
310
* description="EDIT Only - When in single mode, how many items per page should be shown",
317
* description="EDIT Only - The date format to apply to all dates returned by the ticker",
322
* name="allowedAttributes",
324
* description="EDIT Only and SourceId=1 - A comma separated list of attributes that should not be stripped from the feed",
331
* description="EDIT Only and SourceId=1 - A comma separated list of attributes that should be stripped from the feed",
336
* name="backgroundColor",
338
* description="Edit only - A HEX color to use as the background color of this widget",
343
* name="disableDateSort",
345
* description="EDIT Only, SourceId=1 - Should the date sort applied to the feed be disabled?",
350
* name="textDirection",
352
* description="EDIT Only, SourceId=1 - Which direction does the text in the feed use? Available options: ltr, rtl",
357
* name="noDataMessage",
359
* description="EDIT Only - A message to display when no data is returned from the source",
366
* description="EDIT Only, SourceId=1 - Template you'd like to apply, options available: title-only, prominent-title-with-desc-and-name-separator, media-rss-with-title, media-rss-wth-left-hand-text",
371
* name="overrideTemplate",
373
* description="EDIT Only, SourceId=1 - flag (0, 1) override template checkbox",
380
* description="Template for each item, replaces [itemsTemplate] in main template, Pass only with overrideTemplate set to 1 or with sourceId=2 ",
387
* description="Optional StyleSheet Pass only with overrideTemplate set to 1 or with sourceId=2 ",
394
* description="Optional JavaScript, Pass only with overrideTemplate set to 1 ",
401
* description="EDIT Only, SourceId=2 - SQL clause for filter this dataSet",
408
* description="EDIT Only, SourceId=2- SQL clause for how this dataSet should be ordered",
413
* name="useOrderingClause",
415
* description="EDIT Only, SourceId=2 - flag (0,1) Use advanced order clause - set to 1 if ordering is provided",
420
* name="useFilteringClause",
422
* description="EDIT Only, SourceId=2 - flag (0,1) Use advanced filter clause - set to 1 if filter is provided",
427
* name="randomiseItems",
429
* description="A flag (0, 1), whether to randomise the feed",
435
* description="successful operation",
436
* @SWG\Schema(ref="#/definitions/Widget"),
439
* description="Location of the new widget",
445
public function add()
447
$this->setDuration($this->getSanitizer()->getInt('duration', $this->getDuration()));
448
$this->setUseDuration($this->getSanitizer()->getCheckbox('useDuration'));
449
$this->setOption('xmds', true);
450
$this->setOption('sourceId', $this->getSanitizer()->getInt('sourceId'));
451
$this->setOption('uri', urlencode($this->getSanitizer()->getString('uri')));
452
$this->setOption('durationIsPerItem', 1);
453
$this->setOption('updateInterval', 120);
454
$this->setOption('speed', 2);
456
if ($this->getOption('sourceId') == 2)
457
$this->setOption('dataSetId', $this->getSanitizer()->getInt('dataSetId', 0));
459
// New tickers have template override set to 0 by add.
460
// the edit form can then default to 1 when the element doesn't exist (for legacy)
461
$this->setOption('overrideTemplate', 0);
471
public function edit()
473
// Source is selected during add() and cannot be edited.
475
$this->setDuration($this->getSanitizer()->getInt('duration', $this->getDuration()));
476
$this->setUseDuration($this->getSanitizer()->getCheckbox('useDuration'));
477
$this->setOption('xmds', true);
478
$this->setOption('uri', urlencode($this->getSanitizer()->getString('uri')));
479
$this->setOption('updateInterval', $this->getSanitizer()->getInt('updateInterval', 120));
480
$this->setOption('speed', $this->getSanitizer()->getInt('speed', 2));
481
$this->setOption('name', $this->getSanitizer()->getString('name'));
482
$this->setOption('effect', $this->getSanitizer()->getString('effect'));
483
$this->setOption('copyright', $this->getSanitizer()->getString('copyright'));
484
$this->setOption('numItems', $this->getSanitizer()->getInt('numItems'));
485
$this->setOption('takeItemsFrom', $this->getSanitizer()->getString('takeItemsFrom'));
486
$this->setOption('durationIsPerItem', $this->getSanitizer()->getCheckbox('durationIsPerItem'));
487
$this->setOption('randomiseItems', $this->getSanitizer()->getCheckbox('randomiseItems'));
488
$this->setOption('itemsSideBySide', $this->getSanitizer()->getCheckbox('itemsSideBySide'));
489
$this->setOption('upperLimit', $this->getSanitizer()->getInt('upperLimit', 0));
490
$this->setOption('lowerLimit', $this->getSanitizer()->getInt('lowerLimit', 0));
492
$this->setOption('itemsPerPage', $this->getSanitizer()->getInt('itemsPerPage'));
493
$this->setOption('dateFormat', $this->getSanitizer()->getString('dateFormat'));
494
$this->setOption('allowedAttributes', $this->getSanitizer()->getString('allowedAttributes'));
495
$this->setOption('stripTags', $this->getSanitizer()->getString('stripTags'));
496
$this->setOption('backgroundColor', $this->getSanitizer()->getString('backgroundColor'));
497
$this->setOption('disableDateSort', $this->getSanitizer()->getCheckbox('disableDateSort'));
498
$this->setOption('textDirection', $this->getSanitizer()->getString('textDirection'));
499
$this->setOption('overrideTemplate', $this->getSanitizer()->getCheckbox('overrideTemplate'));
500
$this->setOption('templateId', $this->getSanitizer()->getString('templateId'));
501
$this->setRawNode('noDataMessage', $this->getSanitizer()->getParam('noDataMessage', ''));
502
$this->setRawNode('javaScript', $this->getSanitizer()->getParam('javaScript', ''));
505
if ($this->getOption('sourceId') == 2) {
506
// We are a data set, so get the custom filter controls
507
$this->setOption('filter', $this->getSanitizer()->getParam('filter', null));
508
$this->setOption('ordering', $this->getSanitizer()->getString('ordering'));
509
$this->setOption('useOrderingClause', $this->getSanitizer()->getCheckbox('useOrderingClause'));
510
$this->setOption('useFilteringClause', $this->getSanitizer()->getCheckbox('useFilteringClause'));
512
// Order and Filter criteria
513
$orderClauses = $this->getSanitizer()->getStringArray('orderClause');
514
$orderClauseDirections = $this->getSanitizer()->getStringArray('orderClauseDirection');
515
$orderClauseMapping = [];
518
foreach ($orderClauses as $orderClause) {
521
if ($orderClause == '')
524
// Map the stop code received to the stop ref (if there is one)
525
$orderClauseMapping[] = [
526
'orderClause' => $orderClause,
527
'orderClauseDirection' => isset($orderClauseDirections[$i]) ? $orderClauseDirections[$i] : '',
531
$this->setOption('orderClauses', json_encode($orderClauseMapping));
533
$filterClauses = $this->getSanitizer()->getStringArray('filterClause');
534
$filterClauseOperator = $this->getSanitizer()->getStringArray('filterClauseOperator');
535
$filterClauseCriteria = $this->getSanitizer()->getStringArray('filterClauseCriteria');
536
$filterClauseValue = $this->getSanitizer()->getStringArray('filterClauseValue');
537
$filterClauseMapping = [];
540
foreach ($filterClauses as $filterClause) {
543
if ($filterClause == '')
546
// Map the stop code received to the stop ref (if there is one)
547
$filterClauseMapping[] = [
548
'filterClause' => $filterClause,
549
'filterClauseOperator' => isset($filterClauseOperator[$i]) ? $filterClauseOperator[$i] : '',
550
'filterClauseCriteria' => isset($filterClauseCriteria[$i]) ? $filterClauseCriteria[$i] : '',
551
'filterClauseValue' => isset($filterClauseValue[$i]) ? $filterClauseValue[$i] : '',
555
$this->setOption('filterClauses', json_encode($filterClauseMapping));
557
// DataSet Tickers always have Templates provided.
558
$this->setRawNode('template', $this->getSanitizer()->getParam('ta_text', $this->getSanitizer()->getParam('template', null)));
559
$this->setRawNode('css', $this->getSanitizer()->getParam('ta_css', $this->getSanitizer()->getParam('css', null)));
561
} else if ($this->getOption('overrideTemplate') == 1) {
562
// Feed tickers should only use the template if they have override set.
563
$this->setRawNode('template', $this->getSanitizer()->getParam('ta_text', $this->getSanitizer()->getParam('template', null)));
564
$this->setRawNode('css', $this->getSanitizer()->getParam('ta_css', $this->getSanitizer()->getParam('css', null)));
575
public function hoverPreview()
577
$name = $this->getOption('name');
578
$url = urldecode($this->getOption('uri'));
579
$sourceId = $this->getOption('sourceId', 1);
581
// Default Hover window contains a thumbnail, media type and duration
582
$output = '<div class="thumbnail"><img alt="' . $this->module->name . ' thumbnail" src="' . $this->getConfig()->uri('img/forms/' . $this->getModuleType() . '.gif') . '"></div>';
583
$output .= '<div class="info">';
585
$output .= ' <li>' . __('Type') . ': ' . $this->module->name . '</li>';
586
$output .= ' <li>' . __('Name') . ': ' . $name . '</li>';
588
if ($sourceId == 2) {
589
// Get the DataSet name
591
$dataSet = $this->dataSetFactory->getById($this->getOption('dataSetId'));
593
$output .= ' <li>' . __('Source: DataSet named "%s".', $dataSet->dataSet) . '</li>';
594
} catch (NotFoundException $notFoundException) {
595
$this->getLog()->error('Layout Widget without a DataSet. widgetId: ' . $this->getWidgetId());
596
$output .= ' <li>' . __('Warning: No DataSet found.') . '</li>';
600
$output .= ' <li>' . __('Source') . ': <a href="' . $url . '" target="_blank" title="' . __('Source') . '">' . $url . '</a></li>';
603
$output .= ' <li>' . __('Duration') . ': ' . $this->getDuration() . ' ' . __('seconds') . '</li>';
612
* @param int $displayId
614
* @throws XiboException
616
public function getResource($displayId = 0)
618
// Load in the template
620
$isPreview = ($this->getSanitizer()->getCheckbox('preview') == 1);
622
// Replace the View Port Width?
623
$data['viewPortWidth'] = ($isPreview) ? $this->region->width : '[[ViewPortWidth]]';
625
// What is the data source for this ticker?
626
$sourceId = $this->getOption('sourceId', 1);
628
// Information from the Module
629
$itemsSideBySide = $this->getOption('itemsSideBySide', 0);
630
$duration = $this->getCalculatedDurationForGetResource();
631
$durationIsPerItem = $this->getOption('durationIsPerItem', 1);
632
$numItems = $this->getOption('numItems', 0);
633
$takeItemsFrom = $this->getOption('takeItemsFrom', 'start');
634
$itemsPerPage = $this->getOption('itemsPerPage', 0);
636
// Text/CSS subsitution variables.
640
// Get CSS and HTML template from the original template or from the input field
641
if ($sourceId != 2 && $this->getOption('overrideTemplate') == 0) {
642
// Feed tickers without override set.
643
$template = $this->getTemplateById($this->getOption('templateId', 'title-only'));
645
if (isset($template)) {
646
$text = $template['template'];
647
$css = $template['css'];
649
$text = $this->getRawNode('template', '');
650
$css = $this->getRawNode('css', '');
653
// DataSet tickers or feed tickers without overrides.
654
$text = $this->getRawNode('template', '');
655
$css = $this->getRawNode('css', '');
658
// Parse library references on the template
659
$text = $this->parseLibraryReferences($isPreview, $text);
661
// Parse library references on the CSS Node
662
$css = $this->parseLibraryReferences($isPreview, $css);
664
// Get the JavaScript node
665
$javaScript = $this->parseLibraryReferences($isPreview, $this->getRawNode('javaScript', ''));
667
// Handle older layouts that have a direction node but no effect node
668
$oldDirection = $this->getOption('direction', 'none');
670
if ($oldDirection == 'single')
671
$oldDirection = 'noTransition';
672
else if ($oldDirection != 'none')
673
$oldDirection = 'marquee' . ucfirst($oldDirection);
675
$effect = $this->getOption('effect', $oldDirection);
678
'type' => $this->getModuleType(),
680
'duration' => $duration,
681
'durationIsPerItem' => (($durationIsPerItem == 0) ? false : true),
682
'numItems' => $numItems,
683
'takeItemsFrom' => $takeItemsFrom,
684
'itemsPerPage' => $itemsPerPage,
685
'randomiseItems' => $this->getOption('randomiseItems', 0),
686
'speed' => $this->getOption('speed', 1000),
687
'originalWidth' => $this->region->width,
688
'originalHeight' => $this->region->height,
689
'previewWidth' => $this->getSanitizer()->getDouble('width', 0),
690
'previewHeight' => $this->getSanitizer()->getDouble('height', 0),
691
'scaleOverride' => $this->getSanitizer()->getDouble('scale_override', 0)
694
// Generate a JSON string of substituted items.
695
if ($sourceId == 2) {
696
$items = $this->getDataSetItems($displayId, $isPreview, $text);
698
$items = $this->getRssItems($isPreview, $text);
701
// Return empty string if there are no items to show.
702
if (count($items) == 0) {
703
// Do we have a no-data message to display?
704
$noDataMessage = $this->getRawNode('noDataMessage');
706
if ($noDataMessage != '') {
707
$items[] = $noDataMessage;
709
$this->getLog()->error('Request failed for dataSet id=%d. Widget=%d. Due to No Records Found', $this->getOption('dataSetId'), $this->getWidgetId());
714
// Work out how many pages we will be showing.
716
if ($numItems > count($items) || $numItems == 0)
717
$pages = count($items);
719
$pages = ($itemsPerPage > 0) ? ceil($pages / $itemsPerPage) : $pages;
720
$totalDuration = ($durationIsPerItem == 0) ? $duration : ($duration * $pages);
722
// Replace and Control Meta options
723
$data['controlMeta'] = '<!-- NUMITEMS=' . $pages . ' -->' . PHP_EOL . '<!-- DURATION=' . $totalDuration . ' -->';
724
// Replace the head content
727
if ($itemsSideBySide == 1) {
728
$headContent .= '<style type="text/css">';
729
$headContent .= ' .item, .page { float: left; }';
730
$headContent .= '</style>';
733
if ($this->getOption('textDirection') == 'rtl') {
734
$headContent .= '<style type="text/css">';
735
$headContent .= ' #content { direction: rtl; }';
736
$headContent .= '</style>';
739
if ($this->getOption('backgroundColor') != '') {
740
$headContent .= '<style type="text/css">';
741
$headContent .= ' body { background-color: ' . $this->getOption('backgroundColor') . '; }';
742
$headContent .= '</style>';
745
// Add the CSS if it isn't empty
747
$headContent .= '<style type="text/css">' . $css . '</style>';
750
// Add our fonts.css file
751
$headContent .= '<link href="' . (($isPreview) ? $this->getApp()->urlFor('library.font.css') : 'fonts.css') . '" rel="stylesheet" media="screen">';
752
$headContent .= '<style type="text/css">' . file_get_contents($this->getConfig()->uri('css/client.css', true)) . '</style>';
754
// Replace the Head Content with our generated javascript
755
$data['head'] = $headContent;
757
// Add some scripts to the JavaScript Content
758
$javaScriptContent = '<script type="text/javascript" src="' . $this->getResourceUrl('vendor/jquery-1.11.1.min.js') . '"></script>';
760
// Need the marquee plugin?
761
if (stripos($effect, 'marquee') !== false)
762
$javaScriptContent .= '<script type="text/javascript" src="' . $this->getResourceUrl('vendor/jquery.marquee.min.js') . '"></script>';
764
// Need the cycle plugin?
765
if ($effect != 'none')
766
$javaScriptContent .= '<script type="text/javascript" src="' . $this->getResourceUrl('vendor/jquery-cycle-2.1.6.min.js') . '"></script>';
768
$javaScriptContent .= '<script type="text/javascript" src="' . $this->getResourceUrl('xibo-layout-scaler.js') . '"></script>';
769
$javaScriptContent .= '<script type="text/javascript" src="' . $this->getResourceUrl('xibo-text-render.js') . '"></script>';
770
$javaScriptContent .= '<script type="text/javascript" src="' . $this->getResourceUrl('xibo-image-render.js') . '"></script>';
772
$javaScriptContent .= '<script type="text/javascript">';
773
$javaScriptContent .= ' var options = ' . json_encode($options) . ';';
774
$javaScriptContent .= ' var items = ' . json_encode($items) . ';';
775
$javaScriptContent .= ' $(document).ready(function() { ';
776
$javaScriptContent .= ' $("body").xiboLayoutScaler(options); $("#content").xiboTextRender(options, items); $("#content").find("img").xiboImageRender(options); ';
777
$javaScriptContent .= ' }); ';
778
$javaScriptContent .= $javaScript;
779
$javaScriptContent .= '</script>';
781
// Replace the Head Content with our generated javascript
782
$data['javaScript'] = $javaScriptContent;
784
return $this->renderTemplate($data);
790
* @return array|mixed|null
791
* @throws \Xibo\Exception\ConfigurationException
793
private function getRssItems($isPreview, $text)
795
// Make sure we have the cache location configured
796
Library::ensureLibraryExists($this->getConfig()->GetSetting('LIBRARY_LOCATION'));
798
// Create a key to use as a caching key for this item.
799
// the rendered feed will be cached, so it is important the key covers all options.
800
$feedUrl = urldecode($this->getOption('uri'));
802
/** @var \Stash\Item $cache */
803
$cache = $this->getPool()->getItem($this->makeCacheKey(md5($feedUrl)));
804
$cache->setInvalidationMethod(Invalidation::SLEEP, 5000, 15);
806
$this->getLog()->debug('Ticker with RSS source ' . $feedUrl . '. Cache key: ' . $cache->getKey());
808
// Get the document out of the cache
809
$document = $cache->get();
811
// Check our cache to see if the key exists
812
if ($cache->isMiss()) {
813
// Invalid local cache, requery using picofeed.
814
$this->getLog()->debug('Cache Miss, getting RSS items');
816
// Lock this cache item (120 seconds)
820
// Create a Guzzle Client to get the Feed XML
821
$client = new Client();
822
$response = $client->get($feedUrl, $this->getConfig()->getGuzzleProxy([
824
'Accept' => 'application/rss+xml, application/rdf+xml;q=0.8, application/atom+xml;q=0.6, application/xml;q=0.4, text/xml;q=0.4, text/html;q=0.2'
826
'timeout' => 20 // wait no more than 20 seconds: https://github.com/xibosignage/xibo/issues/1401
829
// Pull out the content type
830
$contentType = $response->getHeaderLine('Content-Type');
832
$this->getLog()->debug('Feed returned content-type ' . $contentType);
834
// https://github.com/xibosignage/xibo/issues/1401
835
if (stripos($contentType, 'rss') === false && stripos($contentType, 'xml') === false && stripos($contentType, 'text') === false && stripos($contentType, 'html') === false) {
836
// The content type isn't compatible
837
$this->getLog()->error('Incompatible content type: ' . $contentType);
842
$result = explode('charset=', $contentType);
843
$document['encoding'] = isset($result[1]) ? $result[1] : '';
844
$document['xml'] = $response->getBody()->getContents();
846
// Add this to the cache.
847
$cache->set($document);
848
$cache->expiresAfter($this->getOption('updateInterval', 360) * 60);
851
$this->getPool()->saveDeferred($cache);
853
$this->getLog()->debug('Processed feed and added to the cache for ' . $this->getOption('updateInterval', 360) . ' minutes');
855
} catch (RequestException $requestException) {
856
// Log and return empty?
857
$this->getLog()->error('Unable to get feed: ' . $requestException->getMessage());
858
$this->getLog()->debug($requestException->getTraceAsString());
864
//$this->getLog()->debug(var_export($document, true));
866
// Cache HIT or we've requested
867
// Load the feed XML document into a feed parser
869
// Enable logging if we need to
870
if (LogService::resolveLogLevel($this->getConfig()->GetSetting('audit', 'error')) == \Slim\Log::DEBUG) {
874
// Allowable attributes
875
$clientConfig = new Config();
877
// need a sensible way to set this
878
// https://github.com/fguillot/picoFeed/issues/196
879
//if ($this->getOption('allowedAttributes') != null) {
880
//$clientConfig->setFilterWhitelistedTags(explode(',', $this->getOption('allowedAttributes')));
883
// Get the feed parser
884
$reader = new Reader($clientConfig);
885
$parser = $reader->getParser($feedUrl, $document['xml'], $document['encoding']);
888
$feed = $parser->execute();
891
$feedItems = $feed->getItems();
893
} catch (PicoFeedException $picoFeedException) {
894
// Log and return empty?
895
$this->getLog()->error('Unable to get feed: ' . $picoFeedException->getMessage());
896
$this->getLog()->debug($picoFeedException->getTraceAsString());
900
// Output any PicoFeed logs
901
if (LogService::resolveLogLevel($this->getConfig()->GetSetting('audit', 'error')) == \Slim\Log::DEBUG) {
902
$this->getLog()->debug(var_export(Logger::getMessages(), true));
905
// Parse the text template
907
preg_match_all('/\[.*?\]/', $text, $matches);
909
// Disable date sorting?
910
if ($this->getOption('disableDateSort') == 0 && $this->getOption('randomiseItems', 0) == 0) {
911
// Sort the items array by date
912
usort($feedItems, function($a, $b) {
916
return ($a->getDate() < $b->getDate());
920
// Date format for the feed items
921
$dateFormat = $this->getOption('dateFormat', $this->getConfig()->GetSetting('DATE_FORMAT'));
923
// Set an expiry time for the media
924
$expires = $this->getDate()->parse()->addMinutes($this->getOption('updateInterval', 3600))->format('U');
926
// Render the content now
927
foreach ($feedItems as $item) {
928
/* @var Item $item */
930
// Substitute for all matches in the template
933
// Run through all [] substitutes in $matches
934
foreach ($matches[0] as $sub) {
937
// Does our [] have a | - if so we need to do some special parsing
938
if (strstr($sub, '|') !== false) {
939
// Use the provided name space to extract a tag
941
// Do we have more than 1 | - if we do then we are also interested in getting an attribute
942
if (substr_count($sub, '|') > 1)
943
list($tag, $namespace, $attribute) = explode('|', $sub);
945
list($tag, $namespace) = explode('|', $sub);
947
// Replace some things so that we know what we are looking at
948
$tag = str_replace('[', '', $tag);
949
$namespace = str_replace(']', '', $namespace);
951
if ($attribute !== null)
952
$attribute = str_replace(']', '', $attribute);
954
// What are we looking at
955
$this->getLog()->debug('Namespace: ' . $namespace . ', Tag: ' . $tag . ', Attribute: ' . $attribute);
956
//$this->getLog()->debug('Item content: %s', var_export($item, true));
958
// Are we an image place holder? [tag|image]
959
if ($namespace == 'image') {
960
// Try to get a link for the image
965
if (stripos($item->getEnclosureType(), 'image') > -1) {
966
// Use the link to get the image
967
$link = $item->getEnclosureUrl();
970
$this->getLog()->debug('No image found for Link|image tag using getEnclosureUrl');
973
$this->getLog()->debug('No image found for Link|image tag using getEnclosureType');
978
// Default behaviour just tries to get the content from the tag provided.
979
// it uses the attribute as a namespace if one has been provided
980
if ($attribute != null)
981
$tags = $item->getTag($tag, $attribute);
983
$tags = $item->getTag($tag);
985
if (count($tags) > 0 && !empty($tags[0]))
988
$this->getLog()->debug('Tag not found for [' . $tag . '] attribute [' . $attribute . ']');
991
$this->getLog()->debug('Resolved link: ' . $link);
993
// If we have managed to resolve a link, download it and replace the tag with the downloaded
996
// Grab the profile image
997
$file = $this->mediaFactory->queueDownload('ticker_' . md5($this->getOption('url') . $link), $link, $expires);
999
$replace = '<img src="' . $this->getFileUrl($file, 'image') . '" ' . $attribute . ' />';
1002
// Our namespace is not "image". Which means we are a normal text substitution using a namespace/attribute
1003
if ($attribute != null)
1004
$tags = $item->getTag($tag, $attribute);
1006
$tags = $item->getTag($tag);
1008
// If we find some tags then do the business with them
1009
if ($tags != NULL && count($tags) > 0) {
1010
$replace = $tags[0];
1012
$this->getLog()->debug('Tag not found for ' . $tag . ' attribute ' . $attribute);
1016
// Use the pool of standard tags
1019
$replace = $this->getOption('name');
1023
$replace = $item->getTitle();
1026
case '[Description]':
1027
// Try to get the description tag
1028
if (!$desc = $item->getTag('description')) {
1029
// use content with tags stripped
1030
$replace = strip_tags($item->getContent());
1033
$replace = $desc[0];
1038
$replace = $item->getContent();
1042
$replace = $item->getAuthor();
1046
$replace = $this->getDate()->getLocalDate($item->getDate()->format('U'), $dateFormat);
1050
$replace = $item->getTag('permalink');
1054
$replace = $item->getUrl();
1058
if (stripos($item->getEnclosureType(), 'image') > -1) {
1059
// Use the link to get the image
1060
$link = $item->getEnclosureUrl();
1062
if (!(empty($link))) {
1064
$file = $this->mediaFactory->queueDownload('ticker_' . md5($this->getOption('url') . $link), $link, $expires);
1066
$replace = '<img src="' . $this->getFileUrl($file, 'image') . '"/>';
1068
$this->getLog()->debug('No image found for image tag using getEnclosureUrl');
1071
$this->getLog()->debug('No image found for image tag using getEnclosureType');
1077
if ($this->getOption('stripTags') != '') {
1078
// Handle cache path for HTML serializer
1079
$cachePath = $this->getConfig()->GetSetting('LIBRARY_LOCATION') . 'cache/HTMLPurifier';
1080
if (!is_dir($cachePath))
1083
$config = \HTMLPurifier_Config::createDefault();
1084
$config->set('Cache.SerializerPath', $cachePath);
1085
$config->set('HTML.ForbiddenElements', explode(',', $this->getOption('stripTags')));
1086
$purifier = new \HTMLPurifier($config);
1087
$replace = $purifier->purify($replace);
1090
// Substitute the replacement we have found (it might be '')
1091
$rowString = str_replace($sub, $replace, $rowString);
1094
$items[] = $rowString;
1097
// Process queued downloads
1098
$this->mediaFactory->processDownloads(function($media) {
1100
$this->getLog()->debug((($media->isSaveRequired) ? 'Successfully downloaded ' : 'Download not required for ') . $media->mediaId);
1102
// Tag this layout with this file
1103
$this->assignMedia($media->mediaId);
1106
// Copyright information?
1107
if ($this->getOption('copyright', '') != '') {
1108
$items[] = '<span id="copyright">' . $this->getOption('copyright') . '</span>';
1120
private function getDataSetItems($displayId, $isPreview, $text)
1122
// Extra fields for data sets
1123
$dataSetId = $this->getOption('dataSetId');
1124
$upperLimit = $this->getOption('upperLimit');
1125
$lowerLimit = $this->getOption('lowerLimit');
1130
if ($this->getOption('useOrderingClause', 1) == 1) {
1131
$ordering = $this->GetOption('ordering');
1133
// Build an order string
1134
foreach (json_decode($this->getOption('orderClauses', '[]'), true) as $clause) {
1135
$ordering .= $clause['orderClause'] . ' ' . $clause['orderClauseDirection'] . ',';
1138
$ordering = rtrim($ordering, ',');
1144
if ($this->getOption('useFilteringClause', 1) == 1) {
1145
$filter = $this->GetOption('filter');
1149
foreach (json_decode($this->getOption('filterClauses', '[]'), true) as $clause) {
1153
switch ($clause['filterClauseCriteria']) {
1156
$criteria = 'LIKE \'' . $clause['filterClauseValue'] . '%\'';
1160
$criteria = 'LIKE \'%' . $clause['filterClauseValue'] . '\'';
1164
$criteria = 'LIKE \'%' . $clause['filterClauseValue'] . '%\'';
1168
$criteria = '= \'' . $clause['filterClauseValue'] . '\'';
1171
case 'not-contains':
1172
$criteria = 'NOT LIKE \'%' . $clause['filterClauseValue'] . '%\'';
1175
case 'not-starts-with':
1176
$criteria = 'NOT LIKE \'' . $clause['filterClauseValue'] . '%\'';
1179
case 'not-ends-with':
1180
$criteria = 'NOT LIKE \'%' . $clause['filterClauseValue'] . '\'';
1184
$criteria = '<> \'' . $clause['filterClauseValue'] . '\'';
1187
case 'greater-than':
1188
$criteria = '> \'' . $clause['filterClauseValue'] . '\'';
1192
$criteria = '< \'' . $clause['filterClauseValue'] . '\'';
1200
$filter .= ' ' . $clause['filterClauseOperator'] . ' ';
1202
$filter .= $clause['filterClause'] . ' ' . $criteria;
1206
$this->getLog()->notice('Then template for each row is: ' . $text);
1208
// Set an expiry time for the media
1209
$expires = time() + ($this->getOption('updateInterval', 3600) * 60);
1211
// Combine the column id's with the dataset data
1213
preg_match_all('/\[(.*?)\]/', $text, $matches);
1215
$columnIds = array();
1217
foreach ($matches[1] as $match) {
1218
// Get the column id's we are interested in
1219
$this->getLog()->notice('Matched column: ' . $match);
1221
$col = explode('|', $match);
1222
$columnIds[] = $col[1];
1225
// Create a data set object, to get the results.
1227
$dataSet = $this->dataSetFactory->getById($dataSetId);
1229
// Get an array representing the id->heading mappings
1231
foreach ($columnIds as $dataSetColumnId) {
1232
// Get the column definition this represents
1233
$column = $dataSet->getColumn($dataSetColumnId);
1234
/* @var DataSetColumn $column */
1236
$mappings[$column->heading] = [
1237
'dataSetColumnId' => $dataSetColumnId,
1238
'heading' => $column->heading,
1239
'dataTypeId' => $column->dataTypeId
1243
$this->getLog()->debug('Resolved column mappings: %s', json_encode($columnIds));
1246
'filter' => $filter,
1247
'order' => $ordering,
1248
'displayId' => $displayId
1252
if ($lowerLimit != 0 || $upperLimit != 0) {
1253
// Start should be the lower limit
1254
// Size should be the distance between upper and lower
1255
$filter['start'] = $lowerLimit;
1256
$filter['size'] = $upperLimit - $lowerLimit;
1259
// Set the timezone for SQL
1260
$dateNow = $this->getDate()->parse();
1261
if ($displayId != 0) {
1262
$display = $this->displayFactory->getById($displayId);
1263
$timeZone = $display->getSetting('displayTimeZone', '');
1264
$timeZone = ($timeZone == '') ? $this->getConfig()->GetSetting('defaultTimezone') : $timeZone;
1265
$dateNow->timezone($timeZone);
1266
$this->getLog()->debug('Display Timezone Resolved: %s. Time: %s.', $timeZone, $dateNow->toDateTimeString());
1269
$this->getStore()->setTimeZone($this->getDate()->getLocalDate($dateNow, 'P'));
1271
// Get the data (complete table, filtered)
1272
$dataSetResults = $dataSet->getData($filter);
1274
if (count($dataSetResults) <= 0)
1275
throw new NotFoundException(__('Empty Result Set with filter criteria.'));
1279
foreach ($dataSetResults as $row) {
1280
// For each row, substitute into our template
1283
foreach ($matches[1] as $sub) {
1284
// Pick the appropriate column out
1285
$subs = explode('|', $sub);
1287
// The column header
1289
$replace = $row[$header];
1291
// If the value is empty, then move on
1292
if ($replace != '') {
1293
// Check in the columns array to see if this is a special one
1294
if ($mappings[$header]['dataTypeId'] == 4) {
1296
// Download the image, alter the replace to wrap in an image tag
1297
$file = $this->mediaFactory->queueDownload('ticker_dataset_' . md5($dataSetId . $mappings[$header]['dataSetColumnId'] . $replace), str_replace(' ', '%20', htmlspecialchars_decode($replace)), $expires);
1299
$replace = '<img src="' . $this->getFileUrl($file, 'image') . '"/>';
1301
} else if ($mappings[$header]['dataTypeId'] == 5) {
1303
// The content is the ID of the image
1305
if ($replace !== 0) {
1306
$file = $this->mediaFactory->getById($replace);
1308
// Tag this layout with this file
1309
$this->assignMedia($file->mediaId);
1311
$replace = '<img src="' . $this->getFileUrl($file, 'image') . '" />';
1316
catch (NotFoundException $e) {
1317
$this->getLog()->error('Library Image [%s] not found in DataSetId %d.', $replace, $dataSetId);
1323
$rowString = str_replace('[' . $sub . ']', $replace, $rowString);
1326
$items[] = $rowString;
1329
// Process queued downloads
1330
$this->mediaFactory->processDownloads(function($media) {
1332
$this->getLog()->debug('Successfully downloaded ' . $media->mediaId);
1334
// Tag this layout with this file
1335
$this->assignMedia($media->mediaId);
1340
catch (NotFoundException $e) {
1341
$this->getLog()->debug('getDataSetItems failed for id=%d. Widget=%d. Due to %s - this might be OK if we have a no-data message', $dataSetId, $this->getWidgetId(), $e->getMessage());
1342
$this->getLog()->debug($e->getTraceAsString());
1347
public function isValid()
1349
// Can't be sure because the client does the rendering
1354
public function getModifiedTimestamp($displayId)
1356
$widgetModifiedDt = null;
1358
if ($this->getOption('sourceId', 1) == 2) {
1360
$dataSetId = $this->getOption('dataSetId');
1361
$dataSet = $this->dataSetFactory->getById($dataSetId);
1363
// Set the timestamp
1364
$widgetModifiedDt = $dataSet->lastDataEdit;
1366
// Remote dataSets are kept "active" by required files
1367
if ($dataSet->isRemote) {
1368
// Touch this dataSet
1369
$dataSetCache = $this->getPool()->getItem('/dataset/accessed/' . $dataSet->dataSetId);
1370
$dataSetCache->set('true');
1371
$dataSetCache->expiresAfter($this->getSetting('REQUIRED_FILES_LOOKAHEAD') * 1.5);
1372
$this->getPool()->saveDeferred($dataSetCache);
1376
return $widgetModifiedDt;
1380
public function getCacheDuration()
1382
return $this->getOption('updateInterval', 120) * 60;
1386
public function getCacheKey($displayId)
1388
if ($displayId === 0 || $this->getOption('sourceId', 1) == 2) {
1389
// DataSets might use Display
1390
return $this->getWidgetId() . '_' . $displayId;
1392
// Tickers are non-display specific
1393
return $this->getWidgetId() . (($displayId === 0) ? '_0' : '');
1398
public function getLockKey()
1400
if ($this->getOption('sourceId', 1) == 2) {
1401
// Lock to the dataSetId, because our dataSet might have external images which are downloaded.
1402
return $this->getOption('dataSetId');
1404
// Tickers are locked to the feed
1405
return md5(urldecode($this->getOption('uri')));