From 59f0ce367511b883a097f573c2c26d9d18572600 Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Wed, 13 Dec 2023 15:08:44 +0000 Subject: [PATCH 001/203] Real time data pt1 (#2269) --- .github/workflows/build-container.yaml | 1 + README.md | 3 +- ...0231128144300_real_time_data_migration.php | 55 ++++++++++++++ lib/Controller/DataSet.php | 58 +++++++++++++- lib/Controller/Schedule.php | 54 +++++++++++++ lib/Entity/DataSet.php | 68 ++++++++++++++++- lib/Entity/Schedule.php | 37 ++++++++- lib/Factory/DataSetFactory.php | 19 ++++- lib/Factory/ScheduleFactory.php | 11 ++- lib/Factory/UserGroupFactory.php | 10 +++ lib/Helper/Environment.php | 2 +- lib/Middleware/ListenersMiddleware.php | 7 ++ lib/Service/MediaService.php | 4 + lib/Xmds/Entity/Dependency.php | 1 + .../Listeners/XmdsDataConnectorListener.php | 76 +++++++++++++++++++ lib/Xmds/Soap.php | 39 ++++++++++ ui/src/core/xibo-calendar.js | 10 +-- views/dataset-form-add.twig | 20 +++++ views/dataset-form-edit.twig | 20 ++++- views/dataset-page.twig | 23 ++++++ views/schedule-form-add.twig | 16 ++++ views/schedule-form-edit.twig | 18 +++++ 22 files changed, 530 insertions(+), 22 deletions(-) create mode 100644 db/migrations/20231128144300_real_time_data_migration.php create mode 100644 lib/Xmds/Listeners/XmdsDataConnectorListener.php diff --git a/.github/workflows/build-container.yaml b/.github/workflows/build-container.yaml index b7edecd295..e1026b2f62 100644 --- a/.github/workflows/build-container.yaml +++ b/.github/workflows/build-container.yaml @@ -5,6 +5,7 @@ on: branches: - master - develop + - giacobini - release23 - release33 diff --git a/README.md b/README.md index 730b6ee8f4..441094ecfe 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,8 @@ git clone git@github.com:/xibo-cms.git xibo-cms We maintain the following branches. To contribute to Xibo please also use the `develop` branch as your base. -- develop: Work in progress toward 4.0.x +- giacobini: Work in progress toward 4.1.x +- develop: Bug fixes for 4.0.x - master: Currently 4.0 - release33: Bug fixes for 3.3 - release23: Bug fixes for 2.3 diff --git a/db/migrations/20231128144300_real_time_data_migration.php b/db/migrations/20231128144300_real_time_data_migration.php new file mode 100644 index 0000000000..3bfb807847 --- /dev/null +++ b/db/migrations/20231128144300_real_time_data_migration.php @@ -0,0 +1,55 @@ +. + */ + +use Phinx\Migration\AbstractMigration; + +/** + * Migrations for new real-time data + * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace + */ +class RealTimeDataMigration extends AbstractMigration +{ + public function change(): void + { + $this->table('dataset') + ->addColumn('isRealTime', 'integer', [ + 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, + 'default' => 0, + 'null' => false, + ]) + ->save(); + + $this->table('schedule') + ->addColumn('dataSetId', 'integer', [ + 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_REGULAR, + 'default' => null, + 'null' => true + ]) + ->addColumn('dataSetParams', 'text', [ + 'limit' => \Phinx\Db\Adapter\MysqlAdapter::TEXT_REGULAR, + 'default' => null, + 'null' => true + ]) + ->addForeignKey('dataSetId', 'dataset', 'dataSetId') + ->save(); + } +} diff --git a/lib/Controller/DataSet.php b/lib/Controller/DataSet.php index c97bb2c91e..04010dc233 100644 --- a/lib/Controller/DataSet.php +++ b/lib/Controller/DataSet.php @@ -128,6 +128,13 @@ public function displayPage(Request $request, Response $response) * required=false * ), * @SWG\Parameter( + * name="isRealTime", + * in="query", + * description="Filter by real time", + * type="integer", + * required=false + * ), + * @SWG\Parameter( * name="userId", * in="query", * description="Filter by user Id", @@ -171,6 +178,7 @@ public function grid(Request $request, Response $response) 'dataSet' => $sanitizedParams->getString('dataSet'), 'useRegexForName' => $sanitizedParams->getCheckbox('useRegexForName'), 'code' => $sanitizedParams->getString('code'), + 'isRealTime' => $sanitizedParams->getInt('isRealTime'), 'userId' => $sanitizedParams->getInt('userId'), 'folderId' => $sanitizedParams->getInt('folderId'), 'logicalOperatorName' => $sanitizedParams->getString('logicalOperatorName'), @@ -204,7 +212,9 @@ public function grid(Request $request, Response $response) } if ($this->getUser()->featureEnabled('dataset.modify')) { - if ($user->checkEditable($dataSet)) { + if ($user->checkEditable($dataSet) + && ($dataSet->isRealTime === 0 || $this->getUser()->featureEnabled('dataset.realtime')) + ) { // View Columns $dataSet->buttons[] = array( 'id' => 'dataset_button_viewcolumns', @@ -271,7 +281,10 @@ public function grid(Request $request, Response $response) } } - if ($user->checkDeleteable($dataSet) && $dataSet->isLookup == 0) { + if ($user->checkDeleteable($dataSet) + && $dataSet->isLookup == 0 + && ($dataSet->isRealTime === 0 || $this->getUser()->featureEnabled('dataset.realtime')) + ) { $dataSet->buttons[] = ['divider' => true]; // Delete DataSet $dataSet->buttons[] = [ @@ -381,6 +394,13 @@ public function addForm(Request $request, Response $response) * required=true * ), * @SWG\Parameter( + * name="isRealTime", + * in="formData", + * description="Is this a real time DataSet?", + * type="integer", + * required=true + * ), + * @SWG\Parameter( * name="method", * in="formData", * description="The Request Method GET or POST", @@ -521,6 +541,13 @@ public function addForm(Request $request, Response $response) * required=false * ), * @SWG\Parameter( + * name="dataConnectorScript", + * in="formData", + * description="If isRealTime then provide a script to connect to the data source", + * type="string", + * required=false + * ), + * @SWG\Parameter( * name="folderId", * in="formData", * description="Folder ID to which this object should be assigned to", @@ -556,6 +583,7 @@ public function add(Request $request, Response $response) $dataSet->description = $sanitizedParams->getString('description'); $dataSet->code = $sanitizedParams->getString('code'); $dataSet->isRemote = $sanitizedParams->getCheckbox('isRemote'); + $dataSet->isRealTime = $sanitizedParams->getCheckbox('isRealTime'); $dataSet->userId = $this->getUser()->userId; // Folders @@ -612,6 +640,11 @@ public function add(Request $request, Response $response) // Save $dataSet->save(); + if ($dataSet->isRealTime === 1) { + // Set the script. + $dataSet->saveScript($sanitizedParams->getString('dataConnectorScript')); + } + // Return $this->getState()->hydrate([ 'httpStatus' => 201, @@ -647,6 +680,7 @@ public function editForm(Request $request, Response $response, $id) $this->getState()->setData([ 'dataSet' => $dataSet, 'dataSets' => $this->dataSetFactory->query(), + 'script' => $dataSet->getScript(), ]); return $this->render($request, $response); @@ -706,6 +740,13 @@ public function editForm(Request $request, Response $response, $id) * required=true * ), * @SWG\Parameter( + * name="isRealTime", + * in="formData", + * description="Is this a real time DataSet?", + * type="integer", + * required=true + * ), + * @SWG\Parameter( * name="method", * in="formData", * description="The Request Method GET or POST", @@ -846,6 +887,13 @@ public function editForm(Request $request, Response $response, $id) * required=false * ), * @SWG\Parameter( + * name="dataConnectorScript", + * in="formData", + * description="If isRealTime then provide a script to connect to the data source", + * type="string", + * required=false + * ), + * @SWG\Parameter( * name="folderId", * in="formData", * description="Folder ID to which this object should be assigned to", @@ -872,6 +920,7 @@ public function edit(Request $request, Response $response, $id) $dataSet->description = $sanitizedParams->getString('description'); $dataSet->code = $sanitizedParams->getString('code'); $dataSet->isRemote = $sanitizedParams->getCheckbox('isRemote'); + $dataSet->isRealTime = $sanitizedParams->getCheckbox('isRealTime'); $dataSet->folderId = $sanitizedParams->getInt('folderId', ['default' => $dataSet->folderId]); if ($dataSet->hasPropertyChanged('folderId')) { @@ -907,6 +956,11 @@ public function edit(Request $request, Response $response, $id) $dataSet->save(); + if ($dataSet->isRealTime === 1) { + // Set the script. + $dataSet->saveScript($sanitizedParams->getString('dataConnectorScript')); + } + // Return $this->getState()->hydrate([ 'message' => sprintf(__('Edited %s'), $dataSet->dataSet), diff --git a/lib/Controller/Schedule.php b/lib/Controller/Schedule.php index a14af414aa..57e350bba1 100644 --- a/lib/Controller/Schedule.php +++ b/lib/Controller/Schedule.php @@ -1077,6 +1077,20 @@ public function addForm(Request $request, Response $response, ?string $from, ?in * type="string", * required=false * ), + * @SWG\Parameter( + * name="dataSetId", + * in="formData", + * description="For Data Connector eventTypeId, the DataSet ID", + * type="integer", + * required=false + * ), + * @SWG\Parameter( + * name="dataSetParams", + * in="formData", + * description="For Data Connector eventTypeId, the DataSet params", + * type="string", + * required=false + * ), * @SWG\Response( * response=201, * description="successful operation", @@ -1154,6 +1168,19 @@ public function add(Request $request, Response $response) $schedule->shareOfVoice = null; } + // Fields only collected for data connector events + if ($schedule->eventTypeId === \Xibo\Entity\Schedule::$DATA_CONNECTOR_EVENT) { + $schedule->dataSetId = $sanitizedParams->getInt('dataSetId', [ + 'throw' => function () { + new InvalidArgumentException( + __('Please select a DataSet'), + 'dataSetId' + ); + } + ]); + $schedule->dataSetParams = $sanitizedParams->getString('dataSetParams'); + } + // API request can provide an array of coordinates or valid GeoJSON, handle both cases here. if ($this->isApi($request) && $schedule->isGeoAware === 1) { if ($sanitizedParams->getArray('geoLocation') != null) { @@ -1697,6 +1724,20 @@ public function deleteRecurrence(Request $request, Response $response, $id) * type="string", * required=false * ), + * @SWG\Parameter( + * name="dataSetId", + * in="formData", + * description="For Data Connector eventTypeId, the DataSet ID", + * type="integer", + * required=false + * ), + * @SWG\Parameter( + * name="dataSetParams", + * in="formData", + * description="For Data Connector eventTypeId, the DataSet params", + * type="string", + * required=false + * ), * @SWG\Response( * response=200, * description="successful operation", @@ -1774,6 +1815,19 @@ public function edit(Request $request, Response $response, $id) $schedule->shareOfVoice = null; } + // Fields only collected for data connector events + if ($schedule->eventTypeId === \Xibo\Entity\Schedule::$DATA_CONNECTOR_EVENT) { + $schedule->dataSetId = $sanitizedParams->getInt('dataSetId', [ + 'throw' => function () { + new InvalidArgumentException( + __('Please select a DataSet'), + 'dataSetId' + ); + } + ]); + $schedule->dataSetParams = $sanitizedParams->getString('dataSetParams'); + } + // API request can provide an array of coordinates or valid GeoJSON, handle both cases here. if ($this->isApi($request) && $schedule->isGeoAware === 1) { if ($sanitizedParams->getArray('geoLocation') != null) { diff --git a/lib/Entity/DataSet.php b/lib/Entity/DataSet.php index 50af79f904..56b05b5a17 100644 --- a/lib/Entity/DataSet.php +++ b/lib/Entity/DataSet.php @@ -111,6 +111,12 @@ class DataSet implements \JsonSerializable */ public $isRemote = 0; + /** + * @SWG\Property(description="Flag to indicate whether this DataSet is Real time") + * @var int + */ + public $isRealTime = 0; + /** * @SWG\Property(description="Method to fetch the Data, can be GET or POST") * @var string @@ -947,6 +953,17 @@ public function delete() throw new InvalidArgumentException(__('Cannot delete because DataSet is in use on one or more Layouts.'), 'dataSetId'); } + if ($this->getStore()->exists(' + SELECT `eventId` + FROM `schedule` + WHERE `dataSetId` = :dataSetId + ', ['dataSetId' => $this->dataSetId])) { + throw new InvalidArgumentException( + __('Cannot delete because DataSet is in use on one or more Data Connector schedules.'), + 'dataSetId' + ); + } + // Delete Permissions foreach ($this->permissions as $permission) { /* @var Permission $permission */ @@ -984,8 +1001,10 @@ public function deleteData() */ private function add() { - $columns = 'DataSet, Description, UserID, `code`, `isLookup`, `isRemote`, `lastDataEdit`, `lastClear`, `folderId`, `permissionsFolderId`'; - $values = ':dataSet, :description, :userId, :code, :isLookup, :isRemote, :lastDataEdit, :lastClear, :folderId, :permissionsFolderId'; + $columns = 'DataSet, Description, UserID, `code`, `isLookup`, `isRemote`,'; + $columns .= '`lastDataEdit`, `lastClear`, `folderId`, `permissionsFolderId`, `isRealTime`'; + $values = ':dataSet, :description, :userId, :code, :isLookup, :isRemote,'; + $values .= ':lastDataEdit, :lastClear, :folderId, :permissionsFolderId, :isRealTime'; $params = [ 'dataSet' => $this->dataSet, @@ -994,10 +1013,11 @@ private function add() 'code' => ($this->code == '') ? null : $this->code, 'isLookup' => $this->isLookup, 'isRemote' => $this->isRemote, + 'isRealTime' => $this->isRealTime, 'lastDataEdit' => 0, 'lastClear' => 0, 'folderId' => ($this->folderId === null) ? 1 : $this->folderId, - 'permissionsFolderId' => ($this->permissionsFolderId == null) ? 1 : $this-> permissionsFolderId + 'permissionsFolderId' => ($this->permissionsFolderId == null) ? 1 : $this-> permissionsFolderId, ]; // Insert the extra columns we expect for a remote DataSet @@ -1040,7 +1060,18 @@ private function add() */ private function edit() { - $sql = 'DataSet = :dataSet, Description = :description, userId = :userId, lastDataEdit = :lastDataEdit, `code` = :code, `isLookup` = :isLookup, `isRemote` = :isRemote, `folderId` = :folderId, `permissionsFolderId` = :permissionsFolderId '; + $sql = ' + `DataSet` = :dataSet, + `Description` = :description, + `userId` = :userId, + `lastDataEdit` = :lastDataEdit, + `code` = :code, + `isLookup` = :isLookup, + `isRemote` = :isRemote, + `isRealTime` = :isRealTime, + `folderId` = :folderId, + `permissionsFolderId` = :permissionsFolderId + '; $params = [ 'dataSetId' => $this->dataSetId, 'dataSet' => $this->dataSet, @@ -1050,6 +1081,7 @@ private function edit() 'code' => $this->code, 'isLookup' => $this->isLookup, 'isRemote' => $this->isRemote, + 'isRealTime' => $this->isRealTime, 'folderId' => $this->folderId, 'permissionsFolderId' => $this->permissionsFolderId ]; @@ -1221,4 +1253,32 @@ public function clearCache() $this->getLog()->debug('Force sync detected, clear cache for remote dataSet ID ' . $this->dataSetId); $this->pool->deleteItem('/dataset/cache/' . $this->dataSetId); } + + private function getScriptPath(): string + { + return $this->config->getSetting('LIBRARY_LOCATION') + . 'data_connectors' . DIRECTORY_SEPARATOR + . 'dataSet_' . $this->dataSetId . '.js'; + } + + public function getScript(): string + { + if ($this->isRealTime == 0) { + return ''; + } + + $path = $this->getScriptPath(); + return (file_exists($path)) + ? file_get_contents($path) + : ''; + } + + public function saveScript(string $script): void + { + if ($this->isRealTime == 1) { + $path = $this->getScriptPath(); + file_put_contents($path, $script); + file_put_contents($path . '.md5', md5_file($path)); + } + } } diff --git a/lib/Entity/Schedule.php b/lib/Entity/Schedule.php index a846626ba7..9d8aad774a 100644 --- a/lib/Entity/Schedule.php +++ b/lib/Entity/Schedule.php @@ -62,6 +62,7 @@ class Schedule implements \JsonSerializable public static $MEDIA_EVENT = 7; public static $PLAYLIST_EVENT = 8; public static $SYNC_EVENT = 9; + public static $DATA_CONNECTOR_EVENT = 10; public static $DATE_MIN = 0; public static $DATE_MAX = 2147483647; @@ -304,6 +305,18 @@ class Schedule implements \JsonSerializable */ public $syncGroupId; + /** + * @SWG\Property(description="For data connector events, the dataSetId") + * @var int + */ + public $dataSetId; + + /** + * @SWG\Property(description="For data connector events, the data set parameters") + * @var int + */ + public $dataSetParams; + /** * @SWG\Property(description="The userId of the user that last modified this Schedule") * @var int @@ -672,6 +685,11 @@ public function validate() ); } } + } else if ($this->eventTypeId === Schedule::$DATA_CONNECTOR_EVENT) { + if (!v::intType()->notEmpty()->validate($this->dataSetId)) { + throw new InvalidArgumentException(__('Please select a DataSet for this event.'), 'dataSetId'); + } + $this->campaignId = null; } else { // No event type selected throw new InvalidArgumentException(__('Please select the Event Type'), 'eventTypeId'); @@ -928,7 +946,9 @@ private function add() `modifiedBy`, `createdOn`, `updatedOn`, - `name` + `name`, + `dataSetId`, + `dataSetParams` ) VALUES ( :eventTypeId, @@ -959,7 +979,9 @@ private function add() :modifiedBy, :createdOn, :updatedOn, - :name + :name, + :dataSetId, + :dataSetParams ) ', [ 'eventTypeId' => $this->eventTypeId, @@ -992,7 +1014,9 @@ private function add() 'modifiedBy' => null, 'createdOn' => Carbon::now()->format(DateFormatHelper::getSystemFormat()), 'updatedOn' => null, - 'name' => !empty($this->name) ? $this->name : null + 'name' => !empty($this->name) ? $this->name : null, + 'dataSetId' => !empty($this->dataSetId) ? $this->dataSetId : null, + 'dataSetParams' => !empty($this->dataSetParams) ? $this->dataSetParams : null, ]); } @@ -1030,7 +1054,9 @@ private function edit() `syncGroupId` = :syncGroupId, `modifiedBy` = :modifiedBy, `updatedOn` = :updatedOn, - `name` = :name + `name` = :name, + `dataSetId` = :dataSetId, + `dataSetParams` = :dataSetParams WHERE eventId = :eventId ', [ 'eventTypeId' => $this->eventTypeId, @@ -1061,6 +1087,8 @@ private function edit() 'modifiedBy' => $this->modifiedBy, 'updatedOn' => Carbon::now()->format(DateFormatHelper::getSystemFormat()), 'name' => $this->name, + 'dataSetId' => !empty($this->dataSetId) ? $this->dataSetId : null, + 'dataSetParams' => !empty($this->dataSetParams) ? $this->dataSetParams : null, 'eventId' => $this->eventId, ]); } @@ -1965,6 +1993,7 @@ public static function getEventTypesForm(): array ['eventTypeId' => self::$ACTION_EVENT, 'eventTypeName' => __('Action')], ['eventTypeId' => self::$MEDIA_EVENT, 'eventTypeName' => __('Video/Image')], ['eventTypeId' => self::$PLAYLIST_EVENT, 'eventTypeName' => __('Playlist')], + ['eventTypeId' => self::$DATA_CONNECTOR_EVENT, 'eventTypeName' => __('Data Connector')], ]; } diff --git a/lib/Factory/DataSetFactory.php b/lib/Factory/DataSetFactory.php index 7140e257fc..cd06d60820 100644 --- a/lib/Factory/DataSetFactory.php +++ b/lib/Factory/DataSetFactory.php @@ -202,6 +202,7 @@ public function query($sortOrder = null, $filterBy = []) dataset.`code`, dataset.`isLookup`, dataset.`isRemote`, + dataset.`isRealTime`, dataset.`method`, dataset.`uri`, dataset.`postData`, @@ -262,6 +263,11 @@ public function query($sortOrder = null, $filterBy = []) $params['isRemote'] = $parsedFilter->getInt('isRemote'); } + if ($parsedFilter->getInt('isRealTime') !== null) { + $body .= ' AND dataset.isRealTime = :isRealTime '; + $params['isRealTime'] = $parsedFilter->getInt('isRealTime'); + } + if ($parsedFilter->getString('dataSet') != null) { $terms = explode(',', $parsedFilter->getString('dataSet')); $logicalOperator = $parsedFilter->getString('logicalOperatorName', ['default' => 'OR']); @@ -309,7 +315,18 @@ public function query($sortOrder = null, $filterBy = []) foreach ($this->getStore()->select($sql, $params) as $row) { $entries[] = $this->createEmpty()->hydrate($row, [ - 'intProperties' => ['isLookup', 'isRemote', 'clearRate', 'refreshRate', 'lastDataEdit', 'runsAfter', 'lastSync', 'lastClear', 'ignoreFirstRow'] + 'intProperties' => [ + 'isLookup', + 'isRemote', + 'isRealTime', + 'clearRate', + 'refreshRate', + 'lastDataEdit', + 'runsAfter', + 'lastSync', + 'lastClear', + 'ignoreFirstRow', + ], ]); } diff --git a/lib/Factory/ScheduleFactory.php b/lib/Factory/ScheduleFactory.php index 9428469343..4397186a1e 100644 --- a/lib/Factory/ScheduleFactory.php +++ b/lib/Factory/ScheduleFactory.php @@ -244,7 +244,9 @@ public function getForXmds($displayId, $fromDt, $toDt, $options = []) `daypart`.isCustom, `syncLayout`.layoutId AS syncLayoutId, `syncLayout`.status AS syncLayoutStatus, - `syncLayout`.duration AS syncLayoutDuration + `syncLayout`.duration AS syncLayoutDuration, + `schedule`.dataSetId, + `schedule`.dataSetParams FROM `schedule` INNER JOIN `daypart` ON `daypart`.dayPartId = `schedule`.dayPartId @@ -398,7 +400,9 @@ public function query($sortOrder = null, $filterBy = []) `user`.userName as modifiedByName, `schedule`.createdOn, `schedule`.updatedOn, - `schedule`.name + `schedule`.name, + `schedule`.dataSetId, + `schedule`.dataSetParams '; $body = ' FROM `schedule` @@ -758,7 +762,8 @@ public function query($sortOrder = null, $filterBy = []) 'recurrenceMonthlyRepeatsOn', 'isGeoAware', 'maxPlaysPerHour', - 'modifiedBy' + 'modifiedBy', + 'dataSetId', ] ]); } diff --git a/lib/Factory/UserGroupFactory.php b/lib/Factory/UserGroupFactory.php index 70d3a20aad..e128b30fcf 100644 --- a/lib/Factory/UserGroupFactory.php +++ b/lib/Factory/UserGroupFactory.php @@ -478,6 +478,11 @@ public function getFeatures() 'group' => 'scheduling', 'title' => __('Allow creation of Synchronised Schedules') ], + 'schedule.dataConnector' => [ + 'feature' => 'schedule.dataConnector', + 'group' => 'scheduling', + 'title' => __('Allow creation of Data Connector Schedules') + ], 'daypart.view' => [ 'feature' => 'daypart.view', 'group' => 'scheduling', @@ -528,6 +533,11 @@ public function getFeatures() 'group' => 'library', 'title' => __('Allow edits including deletion to all data contained within a DataSet independently to Layouts') ], + 'dataset.dataConnector' => [ + 'feature' => 'dataset.realtime', + 'group' => 'library', + 'title' => __('Create and update real time DataSets') + ], 'layout.view' => [ 'feature' => 'layout.view', 'group' => 'layout-design', diff --git a/lib/Helper/Environment.php b/lib/Helper/Environment.php index c4affe9eeb..ce0e1d1a7e 100644 --- a/lib/Helper/Environment.php +++ b/lib/Helper/Environment.php @@ -30,7 +30,7 @@ */ class Environment { - public static $WEBSITE_VERSION_NAME = '4.0.5'; + public static $WEBSITE_VERSION_NAME = '4.1.0-alpha'; public static $XMDS_VERSION = '7'; public static $XLF_VERSION = 4; public static $VERSION_REQUIRED = '8.1.0'; diff --git a/lib/Middleware/ListenersMiddleware.php b/lib/Middleware/ListenersMiddleware.php index 42adb914ec..cb7a4874d6 100644 --- a/lib/Middleware/ListenersMiddleware.php +++ b/lib/Middleware/ListenersMiddleware.php @@ -49,6 +49,7 @@ use Xibo\Listener\TaskListener; use Xibo\Listener\WidgetListener; use Xibo\Xmds\Listeners\XmdsAssetsListener; +use Xibo\Xmds\Listeners\XmdsDataConnectorListener; use Xibo\Xmds\Listeners\XmdsFontsListener; use Xibo\Xmds\Listeners\XmdsPlayerBundleListener; use Xibo\Xmds\Listeners\XmdsPlayerVersionListener; @@ -395,6 +396,11 @@ public static function setXmdsListeners(App $app) ->useLogger($c->get('logger')) ->useConfig($c->get('configService')); + $dataConnectorListener = new XmdsDataConnectorListener(); + $dataConnectorListener + ->useLogger($c->get('logger')) + ->useConfig($c->get('configService')); + $dispatcher->addListener('xmds.dependency.list', [$playerBundleListener, 'onDependencyList']); $dispatcher->addListener('xmds.dependency.request', [$playerBundleListener, 'onDependencyRequest']); $dispatcher->addListener('xmds.dependency.list', [$fontsListener, 'onDependencyList']); @@ -402,5 +408,6 @@ public static function setXmdsListeners(App $app) $dispatcher->addListener('xmds.dependency.list', [$playerVersionListner, 'onDependencyList']); $dispatcher->addListener('xmds.dependency.request', [$playerVersionListner, 'onDependencyRequest']); $dispatcher->addListener('xmds.dependency.request', [$assetsListener, 'onDependencyRequest']); + $dispatcher->addListener('xmds.dependency.request', [$dataConnectorListener, 'onDependencyRequest']); } } diff --git a/lib/Service/MediaService.php b/lib/Service/MediaService.php index 7fb0e601ee..d3d6c9cb88 100644 --- a/lib/Service/MediaService.php +++ b/lib/Service/MediaService.php @@ -304,6 +304,10 @@ public static function ensureLibraryExists($libraryFolder) mkdir($libraryFolder . '/assets', 0777, true); } + if (!file_exists($libraryFolder . '/data_connectors')) { + mkdir($libraryFolder . '/data_connectors', 0777, true); + } + // Check that we are now writable - if not then error if (!is_writable($libraryFolder)) { throw new ConfigurationException(__('Library not writable')); diff --git a/lib/Xmds/Entity/Dependency.php b/lib/Xmds/Entity/Dependency.php index 08a3ad4dcb..c1e4631eaf 100644 --- a/lib/Xmds/Entity/Dependency.php +++ b/lib/Xmds/Entity/Dependency.php @@ -31,6 +31,7 @@ class Dependency const LEGACY_ID_OFFSET_FONT = 100000000; const LEGACY_ID_OFFSET_PLAYER_SOFTWARE = 200000000; const LEGACY_ID_OFFSET_ASSET = 300000000; + const LEGACY_ID_OFFSET_DATA_CONNECTOR = 400000000; public $fileType; public $legacyId; diff --git a/lib/Xmds/Listeners/XmdsDataConnectorListener.php b/lib/Xmds/Listeners/XmdsDataConnectorListener.php new file mode 100644 index 0000000000..14eb0f8521 --- /dev/null +++ b/lib/Xmds/Listeners/XmdsDataConnectorListener.php @@ -0,0 +1,76 @@ +. + */ + +namespace Xibo\Xmds\Listeners; + +use Xibo\Event\XmdsDependencyRequestEvent; +use Xibo\Listener\ListenerConfigTrait; +use Xibo\Listener\ListenerLoggerTrait; +use Xibo\Support\Exception\NotFoundException; +use Xibo\Xmds\Entity\Dependency; + +/** + * Listener to handle dependency requests for data connectors. + */ +class XmdsDataConnectorListener +{ + use ListenerLoggerTrait; + use ListenerConfigTrait; + + public function onDependencyRequest(XmdsDependencyRequestEvent $event) + { + // Can we return this type of file? + if ($event->getFileType() === 'data_connector') { + // Set the path + $event->setRelativePathToLibrary('data_connectors/dataSet_' . $event->getRealId() . '.js'); + + // No need to carry on, we've found it. + $event->stopPropagation(); + } + } + + /** + * @throws NotFoundException + */ + public static function getDataConnectorDependency(string $libraryLocation, int $dataSetId): Dependency + { + // Check that this asset is valid. + $path = $libraryLocation + . 'data_connectors' . DIRECTORY_SEPARATOR + . 'dataSet_' . $dataSetId . '.js'; + + if (!file_exists($path)) { + throw new NotFoundException(sprintf(__('Data Connector %s not found'), $path)); + } + + // Return a dependency + return new Dependency( + 'data_connector', + $dataSetId, + (Dependency::LEGACY_ID_OFFSET_DATA_CONNECTOR + $dataSetId) * -1, + $path, + filesize($path), + file_get_contents($path . '.md5'), + true + ); + } +} diff --git a/lib/Xmds/Soap.php b/lib/Xmds/Soap.php index b5a7d6c816..2d576f251a 100644 --- a/lib/Xmds/Soap.php +++ b/lib/Xmds/Soap.php @@ -66,6 +66,7 @@ use Xibo\Support\Exception\GeneralException; use Xibo\Support\Exception\NotFoundException; use Xibo\Xmds\Entity\Dependency; +use Xibo\Xmds\Listeners\XmdsDataConnectorListener; /** * Class Soap @@ -551,10 +552,12 @@ protected function doRequiredFiles( $this->getLog()->debug(count($scheduleEvents) . ' events for eventId ' . $schedule->eventId); + // Sync events $layoutId = ($schedule->eventTypeId == Schedule::$SYNC_EVENT) ? $parsedRow->getInt('syncLayoutId') : $parsedRow->getInt('layoutId'); + // Layout codes (action events) $layoutCode = $parsedRow->getString('actionLayoutCode'); if ($layoutId != null && ( @@ -578,6 +581,18 @@ protected function doRequiredFiles( $this->getLog()->error(sprintf(__('Scheduled Action Event ID %d contains an invalid Layout linked to it by the Layout code.'), $schedule->eventId)); } } + + // Data Connectors + if ($isSupportsDependency && $schedule->eventTypeId === Schedule::$DATA_CONNECTOR_EVENT) { + $this->addDependency( + $newRfIds, + $requiredFilesXml, + $fileElements, + $httpDownloads, + $isSupportsDependency, + XmdsDataConnectorListener::getDataConnectorDependency($libraryLocation, $row['dataSetId']), + ); + } } } catch (\Exception $e) { $this->getLog()->error('Unable to get a list of layouts. ' . $e->getMessage()); @@ -1261,8 +1276,10 @@ protected function doSchedule($serverKey, $hardwareKey, $options = []) $this->getLog()->debug(sprintf('Resolved dependents for Schedule: %s.', json_encode($layoutDependents, JSON_PRETTY_PRINT))); + // Additional nodes. $overlayNodes = null; $actionNodes = null; + $dataConnectorNodes = null; // We must have some results in here by this point foreach ($events as $row) { @@ -1429,6 +1446,23 @@ protected function doSchedule($serverKey, $hardwareKey, $options = []) $action->setAttribute('commandCode', $commandCode); $actionNodes->appendChild($action); + } else if ($eventTypeId === Schedule::$DATA_CONNECTOR_EVENT) { + if ($dataConnectorNodes == null) { + $dataConnectorNodes = $scheduleXml->createElement('dataConnectors'); + } + + $dataConnector = $scheduleXml->createElement('connector'); + $dataConnector->setAttribute('fromdt', $fromDt); + $dataConnector->setAttribute('todt', $toDt); + $dataConnector->setAttribute('scheduleid', $scheduleId); + $dataConnector->setAttribute('priority', $is_priority); + $dataConnector->setAttribute('duration', $row['duration'] ?? 0); + $dataConnector->setAttribute('isGeoAware', $row['isGeoAware'] ?? 0); + $dataConnector->setAttribute('geoLocation', $row['geoLocation'] ?? null); + $dataConnector->setAttribute('dataKey', $row['dataSetId']); + $dataConnector->setAttribute('dataParams', $row['dataSetParams']); + $dataConnector->setAttribute('js', 'dataSet_' . $row['dataSetId'] . '.js'); + $dataConnectorNodes->appendChild($dataConnector); } } } @@ -1442,6 +1476,11 @@ protected function doSchedule($serverKey, $hardwareKey, $options = []) if ($actionNodes != null) { $layoutElements->appendChild($actionNodes); } + + // Add Data Connector nodes if we had any + if ($dataConnectorNodes != null) { + $layoutElements->appendChild($dataConnectorNodes); + } } catch (\Exception $e) { $this->getLog()->error('Error getting the schedule. ' . $e->getMessage()); return new \SoapFault('Sender', 'Unable to get the schedule'); diff --git a/ui/src/core/xibo-calendar.js b/ui/src/core/xibo-calendar.js index 80d5647447..f6eeda05e5 100644 --- a/ui/src/core/xibo-calendar.js +++ b/ui/src/core/xibo-calendar.js @@ -964,27 +964,26 @@ var processScheduleFormElements = function(el) { console.log('Process: eventTypeId, val = ' + fieldVal); var layoutControlDisplay = - (fieldVal == 2 || fieldVal == 6 || fieldVal == 7 || fieldVal == 8) ? 'none' : ''; + (fieldVal == 2 || fieldVal == 6 || fieldVal == 7 || fieldVal == 8 || fieldVal == 10) ? 'none' : ''; var endTimeControlDisplay = (fieldVal == 2 || relativeTime) ? 'none' : ''; var startTimeControlDisplay = (relativeTime && fieldVal != 2) ? 'none' : ''; var dayPartControlDisplay = (fieldVal == 2) ? 'none' : ''; var commandControlDisplay = (fieldVal == 2) ? '' : 'none'; - var scheduleSyncControlDisplay = (fieldVal == 1) ? '' : 'none'; var interruptControlDisplay = (fieldVal == 4) ? '' : 'none'; var actionControlDisplay = (fieldVal == 6) ? '' : 'none'; - var maxPlaysControlDisplay = (fieldVal == 2 || fieldVal == 6) ? 'none' : ''; + var maxPlaysControlDisplay = (fieldVal == 2 || fieldVal == 6 || fieldVal == 10) ? 'none' : ''; var mediaScheduleControlDisplay = (fieldVal == 7) ? '' : 'none'; var playlistScheduleControlDisplay = (fieldVal == 8) ? '' : 'none'; var playlistMediaScheduleControlDisplay = (fieldVal == 7 || fieldVal == 8) ? '' : 'none'; var relativeTimeControlDisplay = (fieldVal == 2 || !relativeTime) ? 'none' : ''; var relativeTimeCheckboxDisplay = (fieldVal == 2) ? 'none' : ''; + var dataConnectorDisplay = fieldVal == 10 ? '' : 'none'; $('.layout-control').css('display', layoutControlDisplay); $('.endtime-control').css('display', endTimeControlDisplay); $('.starttime-control').css('display', startTimeControlDisplay); $('.day-part-control').css('display', dayPartControlDisplay); $('.command-control').css('display', commandControlDisplay); - $('.sync-schedule-control').css('display', scheduleSyncControlDisplay); $('.interrupt-control').css('display', interruptControlDisplay); $('.action-control').css('display', actionControlDisplay); $('.max-plays-control').css('display', maxPlaysControlDisplay); @@ -992,7 +991,8 @@ var processScheduleFormElements = function(el) { $('.playlist-control').css('display', playlistScheduleControlDisplay); $('.media-playlist-control').css('display', playlistMediaScheduleControlDisplay); $('.relative-time-control').css('display', relativeTimeControlDisplay); - $('.relative-time-checkbox').css('display', relativeTimeCheckboxDisplay) + $('.relative-time-checkbox').css('display', relativeTimeCheckboxDisplay); + $('.data-connector-control').css('display', dataConnectorDisplay); // action event type if (fieldVal === 6) { diff --git a/views/dataset-form-add.twig b/views/dataset-form-add.twig index b2f9c6fd14..8e244d76c1 100644 --- a/views/dataset-form-add.twig +++ b/views/dataset-form-add.twig @@ -44,6 +44,7 @@ +
@@ -75,6 +76,12 @@ {% set title %}{% trans "Remote?" %}{% endset %} {% set helpText %}{% trans "Is this DataSet connected to a remote data source?" %}{% endset %} {{ forms.checkbox("isRemote", title, 0, helpText) }} + + {% if currentUser.featureEnabled("dataset.realtime") %} + {% set title %}{% trans "Real time?" %}{% endset %} + {% set helpText %}{% trans "Is this DataSet connected to a real time data source?" %}{% endset %} + {{ forms.checkbox("isRealTime", title, 0, helpText) }} + {% endif %}
@@ -286,6 +293,19 @@ ] %} {{ forms.dropdown("limitPolicy", "single", title, "", options, "typeid", "type", helpText) }}
+ +
+
+
+ {{ "Data Connector JavaScript"|trans }} + + +
+
+
+
+
+
diff --git a/views/dataset-form-edit.twig b/views/dataset-form-edit.twig index 16d02a83ef..5f8f8982b7 100644 --- a/views/dataset-form-edit.twig +++ b/views/dataset-form-edit.twig @@ -44,6 +44,7 @@ +
@@ -84,6 +85,10 @@ {% set helpText %}{% trans "Is this DataSet connected to a remote data source?" %}{% endset %} {{ forms.checkbox("isRemote", title, dataSet.isRemote, helpText) }} + {% set title %}{% trans "Real time?" %}{% endset %} + {% set helpText %}{% trans "Is this DataSet connected to a real time data source?" %}{% endset %} + {{ forms.checkbox("isRealTime", title, dataSet.isRealTime, helpText) }} + {% if dataSet.isActive() %} {{ forms.message("This DataSet has been accessed or updated recently, which means the CMS will keep it active."|trans, "alert alert-success") }} {% endif %} @@ -274,7 +279,7 @@ {% set title %}{% trans "Truncate with no new data?" %}{% endset %} {% set helpText %}{% trans "Should the DataSet data be truncated even if no new data is pulled from the source?" %}{% endset %} - {{ forms.checkbox("truncateOnEmpty", title, dataSet.truncateOnEmpty, helpText) }} + {{ forms.checkbox("truncateOnEmpty", title, dataSet.truncateOnEmpty, helpText) }}F {% set title %}{% trans "Depends on DataSet" %}{% endset %} {% set dataSets = [{dataSetId: null, dataSet: ""}]|merge(dataSets) %} @@ -297,6 +302,19 @@ ] %} {{ forms.dropdown("limitPolicy", "single", title, dataSet.limitPolicy, options, "typeid", "type", helpText) }} + +
+
+
+ {{ "Data Connector JavaScript"|trans }} + + +
+
+
+
+
+
diff --git a/views/dataset-page.twig b/views/dataset-page.twig index decf019367..0b1a5ec040 100644 --- a/views/dataset-page.twig +++ b/views/dataset-page.twig @@ -98,6 +98,7 @@ {% trans "Description" %} {% trans "Code" %} {% trans "Remote?" %} + {% trans "Real time?" %} {% trans "Owner" %} {% trans "Sharing" %} {% trans "Last Sync" %} @@ -153,6 +154,11 @@ responsivePriority: 3, "render": dataTableTickCrossColumn }, + { + data: 'isRealTime', + responsivePriority: 3, + render: dataTableTickCrossColumn, + }, { "data": "owner", responsivePriority: 3 }, { "data": "groupsWithPermissions", @@ -337,6 +343,12 @@ $(dialog).find('#sourceId').on('change', function() { onSourceFieldChanged(dialog); }); + + onRealTimeFieldChanged(dialog); + + $(dialog).find("#isRealTime").on('change', function() { + onRealTimeFieldChanged(dialog); + }); } function onRemoteFieldChanged(dialog) { @@ -350,6 +362,17 @@ } } + function onRealTimeFieldChanged(dialog) { + var isRemote = $(dialog).find("#isRealTime").is(":checked"); + var $remoteTabs = $(dialog).find(".tabForRealTimeDataSet"); + + if (isRemote) { + $remoteTabs.removeClass("d-none"); + } else { + $remoteTabs.addClass("d-none"); + } + } + function onAuthenticationFieldChanged(dialog) { var authentication = $(dialog).find("#authentication").val(); var $authFieldUserName = $(dialog).find(".auth-field-username"); diff --git a/views/schedule-form-add.twig b/views/schedule-form-add.twig index 9d00eb099c..32699e9ce7 100644 --- a/views/schedule-form-add.twig +++ b/views/schedule-form-add.twig @@ -197,6 +197,22 @@ {% set helpText %}{% trans "Please select a command for this Event." %}{% endset %} {{ forms.dropdown("commandId", "single", title, "", [{commandId: "", command: ""}]|merge(commands), "commandId", "command", helpText, "command-control") }} + {% set title %}{% trans "DataSet" %}{% endset %} + {% set helpText %}{% trans "Please select the real time DataSet related to this Data Connector event" %}{% endset %} + + {% set attributes = [ + { name: "data-width", value: "100%" }, + { name: "data-search-url", value: url_for("dataSet.search") ~ "?isRealTime=1" }, + { name: "data-search-term", value: "dataSet" }, + { name: "data-id-property", value: "dataSetId" }, + { name: "data-text-property", value: "dataSet" } + ] %} + {{ forms.dropdown("dataSetId", "single", title, "", null, "dataSetId", "dataSet", helpText, "pagedSelect data-connector-control", "", "", "", attributes) }} + + {% set title %}{% trans "Data Connector Parameters" %}{% endset %} + {% set helpText %}{% trans "Optionally provide any parameters to be used by the Data Connector." %}{% endset %} + {{ forms.input("dataSetParams", title, "", helpText, 'data-connector-control') }} + {% set title %}{% trans "Display Order" %}{% endset %} {% set helpText %}{% trans "Please select the order this event should appear in relation to others when there is more than one event scheduled" %}{% endset %} {{ forms.number("displayOrder", title, "", helpText, 'displayOrder-control') }} diff --git a/views/schedule-form-edit.twig b/views/schedule-form-edit.twig index 4b07f3e56a..0e027151fa 100644 --- a/views/schedule-form-edit.twig +++ b/views/schedule-form-edit.twig @@ -172,6 +172,24 @@ {% set helpText %}{% trans "Please select a command for this Event." %}{% endset %} {{ forms.dropdown("commandId", "single", title, event.commandId, [{commandId: "", command: ""}]|merge(commands), "commandId", "command", helpText, "command-control") }} + {% set title %}{% trans "DataSet" %}{% endset %} + {% set helpText %}{% trans "Please select the real time DataSet related to this Data Connector event" %}{% endset %} + + {% set attributes = [ + { name: "data-width", value: "100%" }, + { name: "data-search-url", value: url_for("dataSet.search") ~ "?isRealTime=1" }, + { name: "data-search-term", value: "dataSet" }, + { name: "data-id-property", value: "dataSetId" }, + { name: "data-text-property", value: "dataSet" }, + { name: "data-initial-key", value: "dataSetId" }, + { name: "data-initial-value", value: event.dataSetId }, + ] %} + {{ forms.dropdown("dataSetId", "single", title, "", event.dataSetId, "dataSetId", "dataSet", helpText, "pagedSelect data-connector-control", "", "", "", attributes) }} + + {% set title %}{% trans "Data Connector Parameters" %}{% endset %} + {% set helpText %}{% trans "Optionally provide any parameters to be used by the Data Connector." %}{% endset %} + {{ forms.input("dataSetParams", title, event.dataSetParams, helpText, 'data-connector-control') }} + {% set title %}{% trans "Display Order" %}{% endset %} {% set helpText %}{% trans "Please select the order this event should appear in relation to others when there is more than one event scheduled" %}{% endset %} {{ forms.number("displayOrder", title, event.displayOrder, helpText, 'displayOrder-control') }} From 147b999bcb7be7cbf383b7f4daef806a003872dd Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Fri, 15 Dec 2023 13:42:20 +0000 Subject: [PATCH 002/203] Real time data pt2 (#2275) * Part 2: add criteria data model, API and fields. * Include new xiboIC changes. --- ...1213120700_schedule_criteria_migration.php | 57 +++++++ lib/Controller/Schedule.php | 33 +++- lib/Dependencies/Controllers.php | 3 +- lib/Dependencies/Factories.php | 8 +- lib/Entity/Schedule.php | 45 +++++- lib/Entity/ScheduleCriteria.php | 144 ++++++++++++++++++ lib/Factory/ScheduleCriteriaFactory.php | 54 +++++++ lib/Factory/ScheduleFactory.php | 6 +- package-lock.json | 4 +- package.json | 2 +- ui/src/core/xibo-calendar.js | 122 ++++++++++++--- views/schedule-form-add.twig | 11 +- views/schedule-form-edit.twig | 11 +- views/schedule-form-templates.twig | 47 +++++- 14 files changed, 509 insertions(+), 38 deletions(-) create mode 100644 db/migrations/20231213120700_schedule_criteria_migration.php create mode 100644 lib/Entity/ScheduleCriteria.php create mode 100644 lib/Factory/ScheduleCriteriaFactory.php diff --git a/db/migrations/20231213120700_schedule_criteria_migration.php b/db/migrations/20231213120700_schedule_criteria_migration.php new file mode 100644 index 0000000000..21cd2f0bfb --- /dev/null +++ b/db/migrations/20231213120700_schedule_criteria_migration.php @@ -0,0 +1,57 @@ +. + */ + +use Phinx\Migration\AbstractMigration; + +/** + * Migrations for schedule criteria + * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace + */ +class ScheduleCriteriaMigration extends AbstractMigration +{ + public function change(): void + { + $this->table('schedule_criteria') + ->addColumn('eventId', 'integer', [ + 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_REGULAR, + 'null' => false, + ]) + ->addColumn('type', 'string', [ + 'limit' => 20, + 'null' => false, + ]) + ->addColumn('metric', 'string', [ + 'limit' => 20, + 'null' => false, + ]) + ->addColumn('condition', 'string', [ + 'limit' => 20, + 'null' => false, + ]) + ->addColumn('value', 'string', [ + 'limit' => 255, + 'null' => false, + ]) + ->addForeignKey('eventId', 'schedule', 'eventId') + ->save(); + } +} diff --git a/lib/Controller/Schedule.php b/lib/Controller/Schedule.php index 57e350bba1..3d8b4bdad5 100644 --- a/lib/Controller/Schedule.php +++ b/lib/Controller/Schedule.php @@ -33,6 +33,7 @@ use Xibo\Factory\DisplayFactory; use Xibo\Factory\DisplayGroupFactory; use Xibo\Factory\LayoutFactory; +use Xibo\Factory\ScheduleCriteriaFactory; use Xibo\Factory\ScheduleExclusionFactory; use Xibo\Factory\ScheduleFactory; use Xibo\Factory\ScheduleReminderFactory; @@ -121,7 +122,8 @@ public function __construct( $dayPartFactory, $scheduleReminderFactory, $scheduleExclusionFactory, - SyncGroupFactory $syncGroupFactory + SyncGroupFactory $syncGroupFactory, + private readonly ScheduleCriteriaFactory $scheduleCriteriaFactory ) { $this->session = $session; $this->scheduleFactory = $scheduleFactory; @@ -1283,6 +1285,20 @@ public function add(Request $request, Response $response) ); } + // Schedule Criteria + $criteria = $sanitizedParams->getArray('criteria'); + if (is_array($criteria) && count($criteria) > 0) { + foreach ($criteria as $item) { + $itemParams = $this->getSanitizer($item); + $criterion = $this->scheduleCriteriaFactory->createEmpty(); + $criterion->metric = $itemParams->getString('metric'); + $criterion->type = $itemParams->getString('type'); + $criterion->condition = $itemParams->getString('condition'); + $criterion->value = $itemParams->getString('value'); + $schedule->criteria[] = $criterion; + } + } + // Ready to do the add $schedule->setDisplayNotifyService($this->displayFactory->getDisplayNotifyService()); if ($schedule->campaignId != null) { @@ -1910,6 +1926,21 @@ public function edit(Request $request, Response $response, $id) $schedule->recurrenceType = null; } + // Schedule Criteria + $schedule->criteria = []; + $criteria = $sanitizedParams->getArray('criteria'); + if (is_array($criteria)) { + foreach ($criteria as $item) { + $itemParams = $this->getSanitizer($item); + $criterion = $this->scheduleCriteriaFactory->createEmpty(); + $criterion->metric = $itemParams->getString('metric'); + $criterion->type = $itemParams->getString('type'); + $criterion->condition = $itemParams->getString('condition'); + $criterion->value = $itemParams->getString('value'); + $schedule->criteria[] = $criterion; + } + } + // Ready to do the add $schedule->setDisplayNotifyService($this->displayFactory->getDisplayNotifyService()); if ($schedule->campaignId != null) { diff --git a/lib/Dependencies/Controllers.php b/lib/Dependencies/Controllers.php index 672dfbb3ad..d20f2d70c9 100644 --- a/lib/Dependencies/Controllers.php +++ b/lib/Dependencies/Controllers.php @@ -472,7 +472,8 @@ public static function registerControllersWithDi() $c->get('dayPartFactory'), $c->get('scheduleReminderFactory'), $c->get('scheduleExclusionFactory'), - $c->get('syncGroupFactory') + $c->get('syncGroupFactory'), + $c->get('scheduleCriteriaFactory') ); $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService')); return $controller; diff --git a/lib/Dependencies/Factories.php b/lib/Dependencies/Factories.php index 1c45bf0df0..262417f41c 100644 --- a/lib/Dependencies/Factories.php +++ b/lib/Dependencies/Factories.php @@ -387,7 +387,8 @@ public static function registerFactoriesWithDi() $c->get('userFactory'), $c->get('scheduleReminderFactory'), $c->get('scheduleExclusionFactory'), - $c->get('user') + $c->get('user'), + $c->get('scheduleCriteriaFactory') ); $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService')); return $repository; @@ -406,6 +407,11 @@ public static function registerFactoriesWithDi() $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService')); return $repository; }, + 'scheduleCriteriaFactory' => function (ContainerInterface $c) { + $repository = new \Xibo\Factory\ScheduleCriteriaFactory(); + $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService')); + return $repository; + }, 'sessionFactory' => function (ContainerInterface $c) { $repository = new \Xibo\Factory\SessionFactory(); $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService')); diff --git a/lib/Entity/Schedule.php b/lib/Entity/Schedule.php index 9d8aad774a..8bab9a9a3b 100644 --- a/lib/Entity/Schedule.php +++ b/lib/Entity/Schedule.php @@ -27,6 +27,7 @@ use Xibo\Factory\CampaignFactory; use Xibo\Factory\DayPartFactory; use Xibo\Factory\DisplayGroupFactory; +use Xibo\Factory\ScheduleCriteriaFactory; use Xibo\Factory\ScheduleExclusionFactory; use Xibo\Factory\ScheduleReminderFactory; use Xibo\Factory\UserFactory; @@ -118,6 +119,16 @@ class Schedule implements \JsonSerializable */ public $scheduleReminders = []; + /** + * @SWG\Property( + * description="Schedule Criteria assigned to this Scheduled Event.", + * type="array", + * @SWG\Items(ref="#/definitions/ScheduleCriteria") + * ) + * @var ScheduleCriteria[] + */ + public $criteria = []; + /** * @SWG\Property( * description="The userId that owns this event." @@ -397,8 +408,19 @@ class Schedule implements \JsonSerializable * @param ScheduleReminderFactory $scheduleReminderFactory * @param ScheduleExclusionFactory $scheduleExclusionFactory */ - public function __construct($store, $log, $dispatcher, $config, $pool, $displayGroupFactory, $dayPartFactory, $userFactory, $scheduleReminderFactory, $scheduleExclusionFactory) - { + public function __construct( + $store, + $log, + $dispatcher, + $config, + $pool, + $displayGroupFactory, + $dayPartFactory, + $userFactory, + $scheduleReminderFactory, + $scheduleExclusionFactory, + private readonly ScheduleCriteriaFactory $scheduleCriteriaFactory + ) { $this->setCommonDependencies($store, $log, $dispatcher); $this->config = $config; $this->pool = $pool; @@ -523,7 +545,8 @@ private function inScheduleLookAhead() public function load($options = []) { $options = array_merge([ - 'loadScheduleReminders' => false + 'loadScheduleReminders' => false, + 'loadScheduleCriteria' => true, ], $options); // If we are already loaded, then don't do it again @@ -538,6 +561,11 @@ public function load($options = []) $this->scheduleReminders = $this->scheduleReminderFactory->query(null, ['eventId'=> $this->eventId]); } + // Load schedule criteria + if ($options['loadScheduleCriteria']) { + $this->criteria = $this->scheduleCriteriaFactory->getByEventId($this->eventId); + } + // Set the original values now that we're loaded. $this->setOriginals(); @@ -812,6 +840,12 @@ public function save($options = []) $this->manageAssignments($isEdit && $options['notify']); } + // Update schedule criteria + foreach ($this->criteria as $criteria) { + $criteria->eventId = $this->eventId; + $criteria->save(); + } + // Notify if ($options['notify']) { // Only if the schedule effects the immediate future - i.e. within the RF Look Ahead @@ -876,6 +910,11 @@ public function delete($options = []) } } + // Delete schedule criteria + $this->getStore()->update('DELETE FROM `schedule_criteria` WHERE `eventId` = :eventId', [ + 'eventId' => $this->eventId, + ]); + if ($this->eventTypeId === self::$SYNC_EVENT) { $this->getStore()->update('DELETE FROM `schedule_sync` WHERE eventId = :eventId', [ 'eventId' => $this->eventId diff --git a/lib/Entity/ScheduleCriteria.php b/lib/Entity/ScheduleCriteria.php new file mode 100644 index 0000000000..770e93f3e9 --- /dev/null +++ b/lib/Entity/ScheduleCriteria.php @@ -0,0 +1,144 @@ +. + */ + +namespace Xibo\Entity; + +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Xibo\Service\LogServiceInterface; +use Xibo\Storage\StorageServiceInterface; +use Xibo\Support\Exception\InvalidArgumentException; +use Xibo\Support\Exception\NotFoundException; + +/** + * Schedule Criteria entity + * @SWG\Definition() + */ +class ScheduleCriteria implements \JsonSerializable +{ + use EntityTrait; + + public int $id; + public int $eventId; + public string $type; + public string $metric; + public string $condition; + public string $value; + + public function __construct( + StorageServiceInterface $store, + LogServiceInterface $logService, + EventDispatcherInterface $dispatcher + ) { + $this->setCommonDependencies($store, $logService, $dispatcher); + } + + /** + * Basic checks to make sure we have all the fields, etc that we need/ + * @return void + * @throws InvalidArgumentException + */ + private function validate(): void + { + if (empty($this->eventId)) { + throw new InvalidArgumentException(__('Criteria must be attached to an event'), 'eventId'); + } + + if (empty($this->metric)) { + throw new InvalidArgumentException(__('Please select a metric'), 'metric'); + } + + if (!in_array($this->condition, ['set', 'lt', 'lte', 'eq', 'neq', 'gt', 'gte', 'contains', 'ncontains'])) { + throw new InvalidArgumentException(__('Please enter a valid condition'), 'condition'); + } + } + + /** + * @throws NotFoundException|InvalidArgumentException + */ + public function save(array $options = []): ScheduleCriteria + { + $options = array_merge([ + 'validate' => true, + 'audit' => true, + ], $options); + + // Validate? + if ($options['validate']) { + $this->validate(); + } + + if (empty($this->id)) { + $this->add(); + } else { + $this->edit(); + } + + if ($options['audit']) { + $this->audit($this->id, 'Saved schedule criteria to event', null, true); + } + + return $this; + } + + /** + * Delete this criteria + * @return void + */ + public function delete(): void + { + $this->getStore()->update('DELETE FROM `schedule_criteria` WHERE `id` = :id', ['id' => $this->id]); + } + + private function add(): void + { + $this->id = $this->getStore()->insert(' + INSERT INTO `schedule_criteria` (`eventId`, `type`, `metric`, `condition`, `value`) + VALUES (:eventId, :type, :metric, :condition, :value) + ', [ + 'eventId' => $this->eventId, + 'type' => $this->type, + 'metric' => $this->metric, + 'condition' => $this->condition, + 'value' => $this->value, + ]); + } + + private function edit(): void + { + $this->getStore()->update(' + UPDATE `schedule_criteria` SET + `eventId` = :eventId, + `type` = :type, + `metric` = :metric, + `condition` = :condition, + `value` = :value + WHERE `id` = :id + ', [ + 'eventId' => $this->eventId, + 'type' => $this->type, + 'metric' => $this->metric, + 'condition' => $this->condition, + 'value' => $this->value, + 'id' => $this->id, + ]); + } +} diff --git a/lib/Factory/ScheduleCriteriaFactory.php b/lib/Factory/ScheduleCriteriaFactory.php new file mode 100644 index 0000000000..253c50e98d --- /dev/null +++ b/lib/Factory/ScheduleCriteriaFactory.php @@ -0,0 +1,54 @@ +. + */ + +namespace Xibo\Factory; + +use Xibo\Entity\ScheduleCriteria; + +/** + * Factory to return schedule criteria entities + */ +class ScheduleCriteriaFactory extends BaseFactory +{ + public function createEmpty(): ScheduleCriteria + { + return new ScheduleCriteria($this->getStore(), $this->getLog(), $this->getDispatcher()); + } + + /** + * Get all criteria for an event + * @param int $eventId + * @return ScheduleCriteria[] + */ + public function getByEventId(int $eventId): array + { + $entries = []; + + foreach ($this->getStore()->select('SELECT * FROM `schedule_criteria` WHERE `eventId` = :eventId', [ + 'eventId' => $eventId, + ]) as $row) { + // Create and hydrate + $entries[] = $this->createEmpty()->hydrate($row); + } + return $entries; + } +} diff --git a/lib/Factory/ScheduleFactory.php b/lib/Factory/ScheduleFactory.php index 4397186a1e..505f4e154c 100644 --- a/lib/Factory/ScheduleFactory.php +++ b/lib/Factory/ScheduleFactory.php @@ -78,7 +78,8 @@ public function __construct( $userFactory, $scheduleReminderFactory, $scheduleExclusionFactory, - $user + $user, + private readonly ScheduleCriteriaFactory $scheduleCriteriaFactory ) { $this->setAclDependencies($user, $userFactory); $this->config = $config; @@ -106,7 +107,8 @@ public function createEmpty() $this->dayPartFactory, $this->userFactory, $this->scheduleReminderFactory, - $this->scheduleExclusionFactory + $this->scheduleExclusionFactory, + $this->scheduleCriteriaFactory ); } diff --git a/package-lock.json b/package-lock.json index b228f12c15..4a49a6d9f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12455,8 +12455,8 @@ } }, "xibo-interactive-control": { - "version": "git+https://github.com/xibosignage/xibo-interactive-control.git#3e5636a69dd7af5032887b75ffc580b3f37ff406", - "from": "git+https://github.com/xibosignage/xibo-interactive-control.git#3e5636a69dd7af5032887b75ffc580b3f37ff406" + "version": "git+https://github.com/xibosignage/xibo-interactive-control.git#e2a65c1beb28d7e115139acc6cff7d2803969d64", + "from": "git+https://github.com/xibosignage/xibo-interactive-control.git#e2a65c1beb28d7e115139acc6cff7d2803969d64" }, "xtend": { "version": "4.0.2", diff --git a/package.json b/package.json index 3b97352d57..9f89c4f8bf 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,6 @@ "serialize-javascript": "^3.1.0", "toastr": "~2.1.4", "underscore": "^1.12.1", - "xibo-interactive-control": "git+https://github.com/xibosignage/xibo-interactive-control.git#3e5636a69dd7af5032887b75ffc580b3f37ff406" + "xibo-interactive-control": "git+https://github.com/xibosignage/xibo-interactive-control.git#e2a65c1beb28d7e115139acc6cff7d2803969d64" } } diff --git a/ui/src/core/xibo-calendar.js b/ui/src/core/xibo-calendar.js index f6eeda05e5..8c6ca456a4 100644 --- a/ui/src/core/xibo-calendar.js +++ b/ui/src/core/xibo-calendar.js @@ -642,7 +642,10 @@ var setupScheduleForm = function(dialog) { // geo schedule var $geoAware = $('#isGeoAware'); var isGeoAware = $geoAware.is(':checked'); - let $form = dialog.find('form') + let $form = dialog.find('form'); + + // Configure the schedule criteria fields. + configureCriteriaFields(dialog); if (isGeoAware) { // without this additional check the map will not load correctly, it should be initialised when we are on the Geo Location tab @@ -732,29 +735,37 @@ var setupScheduleForm = function(dialog) { $recurrenceMonthlyRepeatsOn.find("option").remove().end().append($dayOption).append($weekdayOption).val($recurrenceMonthlyRepeatsOn.data("value")); }); - // Bind to the dialog submit - $("#scheduleAddForm, #scheduleEditForm, #scheduleDeleteForm, #scheduleRecurrenceDeleteForm").submit(function(e) { - e.preventDefault(); + // Bind to the dialog submit + // this should make any changes to the form needed before we submit. + // eslint-disable-next-line max-len + $('#scheduleAddForm, #scheduleEditForm, #scheduleDeleteForm, #scheduleRecurrenceDeleteForm') + .submit(function(e) { + e.preventDefault(); - var form = $(this); + // eslint-disable-next-line no-invalid-this + const $form = $(this); + const data = $form.serializeObject(); - $.ajax({ - type: $(this).attr("method"), - url: $(this).attr("action"), - data: $(this).serialize(), - cache: false, - dataType: "json", - success: function(xhr, textStatus, error) { + // Criteria fields + processCriteriaFields($form, data); - XiboSubmitResponse(xhr, form); + $.ajax({ + type: $form.attr('method'), + url: $form.attr('action'), + data: data, + cache: false, + dataType: 'json', + success: function(xhr, textStatus, error) { + // eslint-disable-next-line new-cap + XiboSubmitResponse(xhr, $form); - if (xhr.success && calendar !== undefined) { - // Reload the Calendar - calendar.options['clearCache'] = true; - calendar.view(); - } - } - }); + if (xhr.success && calendar !== undefined) { + // Reload the Calendar + calendar.options['clearCache'] = true; + calendar.view(); + } + }, + }); }); // Popover @@ -1696,3 +1707,74 @@ var setupSelectForSchedule = function (dialog) { }) }) }; + +/** + * Configure criteria fields on the schedule add/edit forms. + * @param {object} dialog - Dialog object + */ +// eslint-disable-next-line no-unused-vars +const configureCriteriaFields = function(dialog) { + const $fields = dialog.find('#scheduleCriteriaFields'); + if ($fields.length <= 0) { + return; + } + + // We use a template + const templateScheduleCriteriaFields = + Handlebars.compile($('#templateScheduleCriteriaFields').html()); + + // Existing criteria? + if ($fields.data('criteria').length >= 0) { + // Yes there are existing criteria + // Go through each one and add a field row to the form. + let i = 0; + $.each($fields.data('criteria'), function(index, element) { + i++; + element.isAdd = false; + element.i = i; + $fields.append(templateScheduleCriteriaFields(element)); + }); + } + + // Add a row at the end for configuring a new criterion + $fields.append(templateScheduleCriteriaFields({ + isAdd: true, + })); + + // Buttons we've added should be bound. + $fields.on('click', 'button', function(e) { + e.preventDefault(); + // eslint-disable-next-line no-invalid-this + const $button = $(this); + if ($button.data('isAdd')) { + $fields.append(templateScheduleCriteriaFields({ + isAdd: true, + })); + $button.data('isAdd', false); + } else { + $button.closest('.form-group').remove(); + } + }); +}; + +const processCriteriaFields = function($form, data) { + data.criteria = []; + $.each(data.criteria_metric, function(index, element) { + if (element) { + data.criteria.push({ + id: data.criteria_id[index], + type: data.criteria_type[index], + metric: element, + condition: data.criteria_condition[index], + value: data.criteria_value[index], + }); + } + }); + + // Tidy up fields. + delete data['criteria_id']; + delete data['criteria_type']; + delete data['criteria_metric']; + delete data['criteria_criteria']; + delete data['criteria_value']; +}; diff --git a/views/schedule-form-add.twig b/views/schedule-form-add.twig index 32699e9ce7..755e477a71 100644 --- a/views/schedule-form-add.twig +++ b/views/schedule-form-add.twig @@ -1,8 +1,8 @@ {# -/** - * Copyright (C) 2021 Xibo Signage Ltd +/* + * Copyright (C) 2023 Xibo Signage Ltd * - * Xibo - Digital Signage - http://www.xibo.org.uk + * Xibo - Digital Signage - https://xibosignage.com * * This file is part of Xibo. * @@ -45,6 +45,7 @@ + {% set dayPartMessage %}{% trans "Select the start time for this event" %}{% endset %} {% set notDayPartMessage %}{% trans "Start and end time will be defined by the daypart's configuration for this day of the week. Use a repeating schedule to apply this event over multiple days" %}{% endset %} @@ -312,6 +313,10 @@ {{ forms.hidden("geoLocation", "") }} + +
+
+
diff --git a/views/schedule-form-edit.twig b/views/schedule-form-edit.twig index 0e027151fa..c7d9dc3be4 100644 --- a/views/schedule-form-edit.twig +++ b/views/schedule-form-edit.twig @@ -1,8 +1,8 @@ {# -/** - * Copyright (C) 2021 Xibo Signage Ltd +/* + * Copyright (C) 2023 Xibo Signage Ltd * - * Xibo - Digital Signage - http://www.xibo.org.uk + * Xibo - Digital Signage - https://xibosignage.com * * This file is part of Xibo. * @@ -43,6 +43,7 @@ + {% set dayPartMessage %}{% trans "Select the start time for this event" %}{% endset %}
+ +
+
+
diff --git a/views/schedule-form-templates.twig b/views/schedule-form-templates.twig index edc7cbadef..3c6084843f 100644 --- a/views/schedule-form-templates.twig +++ b/views/schedule-form-templates.twig @@ -125,4 +125,49 @@ - \ No newline at end of file + + +{# Schedule Form, criteria fields. #} +{% verbatim %} + +{% endverbatim %} \ No newline at end of file From 2de01222743cf35a156f64615d9fc81d179376d5 Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Tue, 19 Dec 2023 12:50:18 +0000 Subject: [PATCH 003/203] Real time data pt3 (#2277) * Part 3: add to XMDS, misc updates to forms, etc. --- lib/Controller/DataSet.php | 2 ++ lib/Controller/Schedule.php | 4 +-- lib/Entity/Schedule.php | 46 +++++++++++++++++++++++- lib/Xmds/Soap.php | 56 ++++++++++++++++++++++++++++-- views/schedule-form-add.twig | 12 +++++++ views/schedule-form-edit.twig | 12 +++++++ views/schedule-form-templates.twig | 6 ++-- 7 files changed, 130 insertions(+), 8 deletions(-) diff --git a/lib/Controller/DataSet.php b/lib/Controller/DataSet.php index 04010dc233..8497fb4bf8 100644 --- a/lib/Controller/DataSet.php +++ b/lib/Controller/DataSet.php @@ -959,6 +959,8 @@ public function edit(Request $request, Response $response, $id) if ($dataSet->isRealTime === 1) { // Set the script. $dataSet->saveScript($sanitizedParams->getString('dataConnectorScript')); + + // TODO: we should notify displays which have this scheduled to them as data connectors. } // Return diff --git a/lib/Controller/Schedule.php b/lib/Controller/Schedule.php index 3d8b4bdad5..e074b263c4 100644 --- a/lib/Controller/Schedule.php +++ b/lib/Controller/Schedule.php @@ -1295,7 +1295,7 @@ public function add(Request $request, Response $response) $criterion->type = $itemParams->getString('type'); $criterion->condition = $itemParams->getString('condition'); $criterion->value = $itemParams->getString('value'); - $schedule->criteria[] = $criterion; + $schedule->addOrUpdateCriteria($criterion); } } @@ -1937,7 +1937,7 @@ public function edit(Request $request, Response $response, $id) $criterion->type = $itemParams->getString('type'); $criterion->condition = $itemParams->getString('condition'); $criterion->value = $itemParams->getString('value'); - $schedule->criteria[] = $criterion; + $schedule->addOrUpdateCriteria($criterion, $itemParams->getInt('id')); } } diff --git a/lib/Entity/Schedule.php b/lib/Entity/Schedule.php index 8bab9a9a3b..3ecb6ac003 100644 --- a/lib/Entity/Schedule.php +++ b/lib/Entity/Schedule.php @@ -492,6 +492,26 @@ public function setOwner($ownerId) $this->userId = $ownerId; } + /** + * @param ScheduleCriteria $criteria + * @param int|null $id + * @return $this + */ + public function addOrUpdateCriteria(ScheduleCriteria $criteria, ?int $id = null): Schedule + { + // Does this already exist? + foreach ($this->getOriginalValue('criteria') as $existing) { + if ($id !== null && $existing->id === $id) { + $this->criteria[] = $criteria; + return $this; + } + } + + // We didn't find it. + $this->criteria[] = $criteria; + return $this; + } + /** * Are the provided dates within the schedule look ahead * @return bool @@ -545,6 +565,7 @@ private function inScheduleLookAhead() public function load($options = []) { $options = array_merge([ + 'loadDisplayGroups' => true, 'loadScheduleReminders' => false, 'loadScheduleCriteria' => true, ], $options); @@ -554,7 +575,10 @@ public function load($options = []) return; } - $this->displayGroups = $this->displayGroupFactory->getByEventId($this->eventId); + // Load display groups + if ($options['loadDisplayGroups']) { + $this->displayGroups = $this->displayGroupFactory->getByEventId($this->eventId); + } // Load schedule reminders if ($options['loadScheduleReminders']) { @@ -841,9 +865,29 @@ public function save($options = []) } // Update schedule criteria + $criteriaIds = []; foreach ($this->criteria as $criteria) { $criteria->eventId = $this->eventId; $criteria->save(); + + $criteriaIds[] = $criteria->id; + } + + // Remove records that no longer exist. + if (count($criteriaIds) > 0) { + // There are still criteria left + $this->getStore()->update(' + DELETE FROM `schedule_criteria` + WHERE `id` NOT IN (' . implode(',', $criteriaIds) . ') + AND `eventId` = :eventId + ', [ + 'eventId' => $this->eventId, + ]); + } else { + // No criteria left at all (or never was any) + $this->getStore()->update('DELETE FROM `schedule_criteria` WHERE `eventId` = :eventId', [ + 'eventId' => $this->eventId, + ]); } // Notify diff --git a/lib/Xmds/Soap.php b/lib/Xmds/Soap.php index 2d576f251a..aebd272360 100644 --- a/lib/Xmds/Soap.php +++ b/lib/Xmds/Soap.php @@ -1303,6 +1303,21 @@ protected function doSchedule($serverKey, $hardwareKey, $options = []) $this->getLog()->debug(count($scheduleEvents) . ' events for eventId ' . $schedule->eventId); + // Does this event have any criteria? + $criteriaNodes = []; + if (count($scheduleEvents) > 0) { + $schedule->load(['loadDisplayGroups' => false]); + + foreach ($schedule->criteria as $scheduleCriteria) { + $criteriaNode = $scheduleXml->createElement('criteria'); + $criteriaNode->setAttribute('metric', $scheduleCriteria->metric); + $criteriaNode->setAttribute('condition', $scheduleCriteria->condition); + $criteriaNode->setAttribute('type', $scheduleCriteria->type); + $criteriaNode->textContent = $scheduleCriteria->value; + $criteriaNodes[] = $criteriaNode; + } + } + foreach ($scheduleEvents as $scheduleEvent) { $eventTypeId = $row['eventTypeId']; @@ -1389,6 +1404,13 @@ protected function doSchedule($serverKey, $hardwareKey, $options = []) } } + // Add criteria notes + if (count($criteriaNodes) > 0) { + foreach ($criteriaNodes as $criteriaNode) { + $layout->appendChild($criteriaNode); + } + } + $layoutElements->appendChild($layout); } elseif ($eventTypeId == Schedule::$COMMAND_EVENT) { // Add a command node to the schedule @@ -1396,6 +1418,14 @@ protected function doSchedule($serverKey, $hardwareKey, $options = []) $command->setAttribute('date', $fromDt); $command->setAttribute('scheduleid', $scheduleId); $command->setAttribute('code', $commandCode); + + // Add criteria notes + if (count($criteriaNodes) > 0) { + foreach ($criteriaNodes as $criteriaNode) { + $command->appendChild($criteriaNode); + } + } + $layoutElements->appendChild($command); } elseif ($eventTypeId == Schedule::$OVERLAY_EVENT && $options['includeOverlays']) { // Ensure we have a layoutId (we may not if an empty campaign is assigned) @@ -1426,6 +1456,13 @@ protected function doSchedule($serverKey, $hardwareKey, $options = []) $overlay->setAttribute('isGeoAware', $row['isGeoAware'] ?? 0); $overlay->setAttribute('geoLocation', $row['geoLocation'] ?? null); + // Add criteria notes + if (count($criteriaNodes) > 0) { + foreach ($criteriaNodes as $criteriaNode) { + $overlay->appendChild($criteriaNode); + } + } + // Add to the overlays node list $overlayNodes->appendChild($overlay); } elseif ($eventTypeId == Schedule::$ACTION_EVENT) { @@ -1445,6 +1482,13 @@ protected function doSchedule($serverKey, $hardwareKey, $options = []) $action->setAttribute('layoutCode', $row['actionLayoutCode']); $action->setAttribute('commandCode', $commandCode); + // Add criteria notes + if (count($criteriaNodes) > 0) { + foreach ($criteriaNodes as $criteriaNode) { + $action->appendChild($criteriaNode); + } + } + $actionNodes->appendChild($action); } else if ($eventTypeId === Schedule::$DATA_CONNECTOR_EVENT) { if ($dataConnectorNodes == null) { @@ -1459,9 +1503,17 @@ protected function doSchedule($serverKey, $hardwareKey, $options = []) $dataConnector->setAttribute('duration', $row['duration'] ?? 0); $dataConnector->setAttribute('isGeoAware', $row['isGeoAware'] ?? 0); $dataConnector->setAttribute('geoLocation', $row['geoLocation'] ?? null); - $dataConnector->setAttribute('dataKey', $row['dataSetId']); - $dataConnector->setAttribute('dataParams', $row['dataSetParams']); + $dataConnector->setAttribute('dataSetId', $row['dataSetId']); + $dataConnector->setAttribute('dataParams', urlencode($row['dataSetParams'])); $dataConnector->setAttribute('js', 'dataSet_' . $row['dataSetId'] . '.js'); + + // Add criteria notes + if (count($criteriaNodes) > 0) { + foreach ($criteriaNodes as $criteriaNode) { + $dataConnector->appendChild($criteriaNode); + } + } + $dataConnectorNodes->appendChild($dataConnector); } } diff --git a/views/schedule-form-add.twig b/views/schedule-form-add.twig index 755e477a71..83731a1baa 100644 --- a/views/schedule-form-add.twig +++ b/views/schedule-form-add.twig @@ -315,6 +315,18 @@
+ {% set message %}{% trans "Set criteria to determine when this event is active. All conditions must be true for an event to be included in the schedule loop. Events without criteria are always active." %}{% endset %} + {{ forms.message(message) }} + +
+
+
{{ "Type"|trans }}
+
{{ "Metric"|trans }}
+
{{ "Condition"|trans }}
+
{{ "Value"|trans }}
+
+
+
diff --git a/views/schedule-form-edit.twig b/views/schedule-form-edit.twig index c7d9dc3be4..4a5d8095ef 100644 --- a/views/schedule-form-edit.twig +++ b/views/schedule-form-edit.twig @@ -309,6 +309,18 @@
+ {% set message %}{% trans "Set criteria to determine when this event is active. All conditions must be true for an event to be included in the schedule loop. Events without criteria are always active." %}{% endset %} + {{ forms.message(message) }} + +
+
+
{{ "Type"|trans }}
+
{{ "Metric"|trans }}
+
{{ "Condition"|trans }}
+
{{ "Value"|trans }}
+
+
+
diff --git a/views/schedule-form-templates.twig b/views/schedule-form-templates.twig index 3c6084843f..1a9578f8c4 100644 --- a/views/schedule-form-templates.twig +++ b/views/schedule-form-templates.twig @@ -135,8 +135,8 @@
@@ -162,7 +162,7 @@
From fb1be9b5a0f84c56deef8dc999fc6916102c727e Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Thu, 21 Dec 2023 07:56:45 +0000 Subject: [PATCH 004/203] Real time data pt4 (#2279) Part 4: * improved data connector editing/testing interface. * proxy requests via CMS (for CORS). * base module for real time data which allows the selection of a dataset. * user created module templates including basic UI (needs refinement) --- ...155800_user_module_templates_migration.php | 53 +++++ lib/Controller/DataSet.php | 208 +++++++++++++++++- lib/Controller/Developer.php | 181 +++++++++++++++ lib/Dependencies/Controllers.php | 8 + lib/Entity/ModuleTemplate.php | 123 ++++++++++- lib/Factory/ModuleTemplateFactory.php | 136 ++++++++++-- lib/Factory/UserGroupFactory.php | 5 + lib/Widget/Render/WidgetHtmlRenderer.php | 12 +- lib/routes-web.php | 25 +++ lib/routes.php | 1 + modules/realtime-dataset.xml | 44 ++++ views/authed-sidebar.twig | 9 + views/authed-topbar.twig | 14 +- views/dataset-data-connector-page.twig | 202 +++++++++++++++++ views/dataset-data-connector-test-page.twig | 122 ++++++++++ views/dataset-form-add.twig | 14 -- views/dataset-form-edit.twig | 20 +- views/dataset-page.twig | 19 +- views/developer-template-form-add.twig | 57 +++++ views/developer-template-form-edit.twig | 59 +++++ views/developer-template-page.twig | 121 ++++++++++ 21 files changed, 1348 insertions(+), 85 deletions(-) create mode 100644 db/migrations/20231220155800_user_module_templates_migration.php create mode 100644 lib/Controller/Developer.php create mode 100755 modules/realtime-dataset.xml create mode 100644 views/dataset-data-connector-page.twig create mode 100644 views/dataset-data-connector-test-page.twig create mode 100644 views/developer-template-form-add.twig create mode 100644 views/developer-template-form-edit.twig create mode 100644 views/developer-template-page.twig diff --git a/db/migrations/20231220155800_user_module_templates_migration.php b/db/migrations/20231220155800_user_module_templates_migration.php new file mode 100644 index 0000000000..e02fc01adb --- /dev/null +++ b/db/migrations/20231220155800_user_module_templates_migration.php @@ -0,0 +1,53 @@ +. + */ + +use Phinx\Migration\AbstractMigration; + +/** + * Migrations for adding user supplied module templates + * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace + */ +class UserModuleTemplatesMigration extends AbstractMigration +{ + public function change(): void + { + $this->table('module_templates') + ->addColumn('templateId', 'string', [ + 'limit' => 50, + 'null' => false, + ]) + ->addColumn('dataType', 'string', [ + 'limit' => 50, + 'null' => false, + ]) + ->addColumn('xml', 'text', [ + 'limit' => \Phinx\Db\Adapter\MysqlAdapter::TEXT_MEDIUM, + 'null' => false, + ]) + ->addColumn('enabled', 'integer', [ + 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, + 'null' => false, + 'default' => 1, + ]) + ->save(); + } +} diff --git a/lib/Controller/DataSet.php b/lib/Controller/DataSet.php index 8497fb4bf8..02d46f7714 100644 --- a/lib/Controller/DataSet.php +++ b/lib/Controller/DataSet.php @@ -21,6 +21,9 @@ */ namespace Xibo\Controller; +use GuzzleHttp\Client; +use GuzzleHttp\Exception\RequestException; +use Illuminate\Support\Str; use Slim\Http\Response as Response; use Slim\Http\ServerRequest as Request; use Xibo\Factory\DataSetColumnFactory; @@ -172,7 +175,7 @@ public function grid(Request $request, Response $response) // Embed? $embed = ($sanitizedParams->getString('embed') != null) ? explode(',', $sanitizedParams->getString('embed')) : []; - + $filter = [ 'dataSetId' => $sanitizedParams->getInt('dataSetId'), 'dataSet' => $sanitizedParams->getString('dataSet'), @@ -212,9 +215,7 @@ public function grid(Request $request, Response $response) } if ($this->getUser()->featureEnabled('dataset.modify')) { - if ($user->checkEditable($dataSet) - && ($dataSet->isRealTime === 0 || $this->getUser()->featureEnabled('dataset.realtime')) - ) { + if ($user->checkEditable($dataSet)) { // View Columns $dataSet->buttons[] = array( 'id' => 'dataset_button_viewcolumns', @@ -231,6 +232,17 @@ public function grid(Request $request, Response $response) 'text' => __('View RSS') ); + if ($this->getUser()->featureEnabled('dataset.realtime') && $dataSet->isRealTime === 1) { + $dataSet->buttons[] = [ + 'id' => 'dataset_button_view_data_connector', + 'url' => $this->urlFor($request, 'dataSet.dataConnector.view', [ + 'id' => $dataSet->dataSetId + ]), + 'class' => 'XiboRedirectButton', + 'text' => __('View Data Connector'), + ]; + } + // Divider $dataSet->buttons[] = ['divider' => true]; @@ -640,11 +652,6 @@ public function add(Request $request, Response $response) // Save $dataSet->save(); - if ($dataSet->isRealTime === 1) { - // Set the script. - $dataSet->saveScript($sanitizedParams->getString('dataConnectorScript')); - } - // Return $this->getState()->hydrate([ 'httpStatus' => 201, @@ -951,16 +958,74 @@ public function edit(Request $request, Response $response, $id) $dataSet->ignoreFirstRow = $sanitizedParams->getCheckbox('ignoreFirstRow'); $dataSet->rowLimit = $sanitizedParams->getInt('rowLimit'); $dataSet->limitPolicy = $sanitizedParams->getString('limitPolicy') ?? 'stop'; - $dataSet->csvSeparator = ($dataSet->sourceId === 2) ? $sanitizedParams->getString('csvSeparator') ?? ',' : null; + $dataSet->csvSeparator = ($dataSet->sourceId === 2) + ? $sanitizedParams->getString('csvSeparator') ?? ',' + : null; } $dataSet->save(); + // Return + $this->getState()->hydrate([ + 'message' => sprintf(__('Edited %s'), $dataSet->dataSet), + 'id' => $dataSet->dataSetId, + 'data' => $dataSet + ]); + + return $this->render($request, $response); + } + + /** + * Edit DataSet Data Connector + * @param Request $request + * @param Response $response + * @param $id + * @return \Psr\Http\Message\ResponseInterface|Response + * @throws GeneralException + * + * @SWG\Put( + * path="/dataset/dataConnector/{dataSetId}", + * operationId="dataSetDataConnectorEdit", + * tags={"dataset"}, + * summary="Edit DataSet Data Connector", + * description="Edit a DataSet Data Connector", + * @SWG\Parameter( + * name="dataSetId", + * in="path", + * description="The DataSet ID", + * type="integer", + * required=true + * ), + * @SWG\Parameter( + * name="dataConnectorScript", + * in="formData", + * description="If isRealTime then provide a script to connect to the data source", + * type="string", + * required=false + * ), + * @SWG\Response( + * response=200, + * description="successful operation", + * @SWG\Schema(ref="#/definitions/DataSet") + * ) + * ) + */ + public function updateDataConnector(Request $request, Response $response, $id) + { + $dataSet = $this->dataSetFactory->getById($id); + $sanitizedParams = $this->getSanitizer($request->getParams()); + + if (!$this->getUser()->checkEditable($dataSet)) { + throw new AccessDeniedException(); + } + if ($dataSet->isRealTime === 1) { // Set the script. - $dataSet->saveScript($sanitizedParams->getString('dataConnectorScript')); + $dataSet->saveScript($sanitizedParams->getParam('dataConnectorScript')); // TODO: we should notify displays which have this scheduled to them as data connectors. + } else { + throw new InvalidArgumentException(__('This DataSet does not have a data connector'), 'isRealTime'); } // Return @@ -1626,4 +1691,125 @@ public function clearCache(Request $request, Response $response, $id) return $this->render($request, $response); } + + /** + * Real-time data script editor + * @param Request $request + * @param Response $response + * @param $id + * @return \Psr\Http\Message\ResponseInterface|Response + * @throws GeneralException + */ + public function dataConnectorView(Request $request, Response $response, $id) + { + $dataSet = $this->dataSetFactory->getById($id); + + if (!$this->getUser()->checkEditable($dataSet)) { + throw new AccessDeniedException(); + } + + $dataSet->load(); + + $this->getState()->template = 'dataset-data-connector-page'; + $this->getState()->setData([ + 'dataSet' => $dataSet, + 'script' => $dataSet->getScript(), + ]); + + return $this->render($request, $response); + } + + /** + * Real-time data script test + * @param Request $request + * @param Response $response + * @param $id + * @return \Psr\Http\Message\ResponseInterface|Response + * @throws GeneralException + */ + public function dataConnectorTest(Request $request, Response $response, $id) + { + $dataSet = $this->dataSetFactory->getById($id); + + if (!$this->getUser()->checkEditable($dataSet)) { + throw new AccessDeniedException(); + } + + $dataSet->load(); + + $this->getState()->template = 'dataset-data-connector-test-page'; + $this->getState()->setData([ + 'dataSet' => $dataSet, + 'script' => $dataSet->getScript(), + ]); + + return $this->render($request, $response); + } + + /** + * Real-time data script test + * @param Request $request + * @param Response $response + * @param $id + * @return \Psr\Http\Message\ResponseInterface|Response + * @throws GeneralException + */ + public function dataConnectorRequest(Request $request, Response $response, $id) + { + $dataSet = $this->dataSetFactory->getById($id); + + if (!$this->getUser()->checkEditable($dataSet)) { + throw new AccessDeniedException(); + } + + $params = $this->getSanitizer($request->getParams()); + $url = $params->getString('url'); + $method = $params->getString('method', ['default' => 'GET']); + $headers = $params->getArray('headers'); + $body = $params->getArray('body'); + + // Verify that the requested URL appears in the script somewhere. + $script = $dataSet->getScript(); + + if (!Str::contains($script, $url)) { + throw new InvalidArgumentException(__('URL not found in data connector script'), 'url'); + } + + // Make the request + $options = []; + if (is_array($headers)) { + $options['headers'] = $headers; + } + + if ($method === 'GET') { + $options['query'] = $body; + } else { + $options['body'] = $body; + } + + $this->getLog()->debug('dataConnectorRequest: making request with options ' . var_export($options, true)); + + // Use guzzle to make the request + try { + $client = new Client(); + $remoteResponse = $client->request($method, $url, $options); + + // Format the response + $response->getBody()->write($remoteResponse->getBody()->getContents()); + $response = $response->withAddedHeader('Content-Type', $remoteResponse->getHeader('Content-Type')[0]); + $response = $response->withStatus($remoteResponse->getStatusCode()); + } catch (RequestException $exception) { + $this->getLog()->error('dataConnectorRequest: error with request: ' . $exception->getMessage()); + + if ($exception->hasResponse()) { + $remoteResponse = $exception->getResponse(); + $response = $response->withStatus($remoteResponse->getStatusCode()); + $response->getBody()->write($remoteResponse->getBody()->getContents()); + } else { + $response = $response->withStatus(500); + } + } + + return $response; + } } diff --git a/lib/Controller/Developer.php b/lib/Controller/Developer.php new file mode 100644 index 0000000000..a7021d540e --- /dev/null +++ b/lib/Controller/Developer.php @@ -0,0 +1,181 @@ +. + */ +namespace Xibo\Controller; + +use Slim\Http\Response as Response; +use Slim\Http\ServerRequest as Request; +use Xibo\Factory\ModuleFactory; +use Xibo\Factory\ModuleTemplateFactory; +use Xibo\Support\Exception\AccessDeniedException; +use Xibo\Support\Exception\InvalidArgumentException; + +/** + * Class Module + * @package Xibo\Controller + */ +class Developer extends Base +{ + public function __construct( + private readonly ModuleFactory $moduleFactory, + private readonly ModuleTemplateFactory $moduleTemplateFactory + ) { + } + + /** + * Display the module page + * @param Request $request + * @param Response $response + * @return \Slim\Http\Response + * @throws \Xibo\Support\Exception\GeneralException + */ + public function displayTemplatePage(Request $request, Response $response): Response + { + $this->getState()->template = 'developer-template-page'; + + return $this->render($request, $response); + } + + /** + * @param \Slim\Http\ServerRequest $request + * @param \Slim\Http\Response $response + * @return \Slim\Http\Response + * @throws \Xibo\Support\Exception\GeneralException + */ + public function templateGrid(Request $request, Response $response): Response + { + $templates = $this->moduleTemplateFactory->loadUserTemplates(); + + foreach ($templates as $template) { + if ($this->isApi($request)) { + break; + } + + $template->includeProperty('buttons'); + + // Edit button + $template->buttons[] = [ + 'id' => 'template_button_edit', + 'url' => $this->urlFor($request, 'developer.templates.form.edit', ['id' => $template->id]), + 'text' => __('Edit') + ]; + } + + $this->getState()->template = 'grid'; + $this->getState()->recordsTotal = 0; + $this->getState()->setData($templates); + + return $this->render($request, $response); + } + + /** + * Shows an add form for a module template + * @param Request $request + * @param Response $response + * @return \Slim\Http\Response + * @throws \Xibo\Support\Exception\GeneralException + */ + public function templateAddForm(Request $request, Response $response): Response + { + $this->getState()->template = 'developer-template-form-add'; + return $this->render($request, $response); + } + + /** + * Shows an edit form for a module template + * @param Request $request + * @param Response $response + * @param $id + * @return \Slim\Http\Response + * @throws \Xibo\Support\Exception\GeneralException + */ + public function templateEditForm(Request $request, Response $response, $id): Response + { + $template = $this->moduleTemplateFactory->getUserTemplateById($id); + if ($template->ownership !== 'user') { + throw new AccessDeniedException(); + } + + $this->getState()->template = 'developer-template-form-edit'; + $this->getState()->setData([ + 'id' => $id, + 'template' => $template, + 'xml' => $template->getXml(), + ]); + + return $this->render($request, $response); + } + + /** + * Add a module template + * @param Request $request + * @param Response $response + * @return \Slim\Http\Response + * @throws \Xibo\Support\Exception\GeneralException + */ + public function templateAdd(Request $request, Response $response): Response + { + // When adding a template we just save the XML + $params = $this->getSanitizer($request->getParams()); + $xml = $params->getParam('xml', ['throw' => function () { + throw new InvalidArgumentException(__('Please supply the templates XML'), 'xml'); + }]); + + $template = $this->moduleTemplateFactory->createUserTemplate($xml); + $template->save(); + + $this->getState()->hydrate([ + 'httpState' => 201, + 'message' => __('Added'), + 'id' => $template->id, + ]); + + return $this->render($request, $response); + } + + /** + * Edit a module template + * @param Request $request + * @param Response $response + * @param $id + * @return Response + * @throws \Xibo\Support\Exception\GeneralException + */ + public function templateEdit(Request $request, Response $response, $id): Response + { + $template = $this->moduleTemplateFactory->getUserTemplateById($id); + + $params = $this->getSanitizer($request->getParams()); + $xml = $params->getParam('xml', ['throw' => function () { + throw new InvalidArgumentException(__('Please supply the templates XML'), 'xml'); + }]); + + // TODO: some checking that the templateId/dataType hasn't changed. + $template->setXml($xml); + $template->save(); + + if ($params->getCheckbox('isInvalidateWidget')) { + $template->invalidate(); + } + + return $this->render($request, $response); + } +} diff --git a/lib/Dependencies/Controllers.php b/lib/Dependencies/Controllers.php index d20f2d70c9..479676aa75 100644 --- a/lib/Dependencies/Controllers.php +++ b/lib/Dependencies/Controllers.php @@ -147,6 +147,14 @@ public static function registerControllersWithDi() $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService')); return $controller; }, + '\Xibo\Controller\Developer' => function (ContainerInterface $c) { + $controller = new \Xibo\Controller\Developer( + $c->get('moduleFactory'), + $c->get('moduleTemplateFactory') + ); + $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService')); + return $controller; + }, '\Xibo\Controller\Display' => function (ContainerInterface $c) { $controller = new \Xibo\Controller\Display( $c->get('store'), diff --git a/lib/Entity/ModuleTemplate.php b/lib/Entity/ModuleTemplate.php index 8580d7ea1f..321f5720e3 100644 --- a/lib/Entity/ModuleTemplate.php +++ b/lib/Entity/ModuleTemplate.php @@ -37,6 +37,9 @@ class ModuleTemplate implements \JsonSerializable use EntityTrait; use ModulePropertyTrait; + /** @var int The database ID */ + public $id; + /** * @SWG\Property() * @var string The templateId @@ -135,13 +138,19 @@ class ModuleTemplate implements \JsonSerializable /** @var string A data parser for elements */ public $onElementParseData; - + /** @var bool $isError Does this module have any errors? */ public $isError; /** @var string[] $errors An array of errors this module has. */ public $errors; + /** @var string $ownership Who owns this file? system|custom|user */ + public $ownership; + + /** @var string $xml The XML used to build this template */ + private $xml; + /** @var \Xibo\Factory\ModuleTemplateFactory */ private $moduleTemplateFactory; @@ -151,12 +160,14 @@ class ModuleTemplate implements \JsonSerializable * @param LogServiceInterface $log * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher * @param \Xibo\Factory\ModuleTemplateFactory $moduleTemplateFactory + * @param string $file The file this template resides in */ public function __construct( StorageServiceInterface $store, LogServiceInterface $log, EventDispatcherInterface $dispatcher, - ModuleTemplateFactory $moduleTemplateFactory + ModuleTemplateFactory $moduleTemplateFactory, + private readonly string $file ) { $this->setCommonDependencies($store, $log, $dispatcher); $this->moduleTemplateFactory = $moduleTemplateFactory; @@ -170,4 +181,112 @@ public function getAssets(): array { return $this->assets; } + + /** + * Set XML for this Module Template + * @param string $xml + * @return void + */ + public function setXml(string $xml): void + { + $this->xml = $xml; + } + + /** + * Get XML for this Module Template + * @return string + */ + public function getXml(): string + { + if ($this->file === 'database') { + return $this->xml; + } else { + return file_get_contents($this->file); + } + } + + /** + * Save + * @return void + */ + public function save(): void + { + if ($this->file === 'database') { + if ($this->id === null) { + $this->add(); + } else { + $this->edit(); + } + } + } + + /** + * Delete + * @return void + */ + public function delete(): void + { + if ($this->file === 'database') { + $this->getStore()->update('DELETE FROM module_templates WHERE id = :id', [ + 'id' => $this->id + ]); + } + } + + /** + * Invalidate this module template for any widgets that use it + * @return void + */ + public function invalidate(): void + { + // TODO: can we improve this via the event mechanism instead? + $this->getStore()->update(' + UPDATE `widget` SET modifiedDt = :now + WHERE widgetId IN ( + SELECT widgetId + FROM widgetoption + WHERE `option` = \'templateId\' + AND `value` = :templateId + ) + ', [ + 'now' => time(), + 'templateId' => $this->templateId, + ]); + } + + /** + * Add + * @return void + */ + private function add(): void + { + $this->id = $this->getStore()->insert(' + INSERT INTO `module_templates` (`templateId`, `dataType`, `xml`) + VALUES (:templateId, :dataType, :xml) + ', [ + 'templateId' => $this->templateId, + 'dataType' => $this->dataType, + 'xml' => $this->xml, + ]); + } + + /** + * Edit + * @return void + */ + private function edit(): void + { + $this->getStore()->update(' + UPDATE `module_templates` SET + `templateId` = :templateId, + `dataType`= :dataType, + `xml` = :xml + WHERE `id` = :id + ', [ + 'templateId' => $this->templateId, + 'dataType' => $this->dataType, + 'xml' => $this->xml, + 'id' => $this->id, + ]); + } } diff --git a/lib/Factory/ModuleTemplateFactory.php b/lib/Factory/ModuleTemplateFactory.php index e7baf0b8e6..716ae427f5 100644 --- a/lib/Factory/ModuleTemplateFactory.php +++ b/lib/Factory/ModuleTemplateFactory.php @@ -71,6 +71,21 @@ public function getByTypeAndId(string $type, string $id): ModuleTemplate throw new NotFoundException(sprintf(__('%s not found for %s'), $type, $id)); } + /** + * @param int $id + * @return \Xibo\Entity\ModuleTemplate + * @throws \Xibo\Support\Exception\NotFoundException + */ + public function getUserTemplateById(int $id): ModuleTemplate + { + $templates = $this->loadUserTemplates($id); + if (count($templates) !== 1) { + throw new NotFoundException(sprintf(__('Template not found for %s'), $id)); + } + + return $templates[0]; + } + /** * @param string $dataType * @param string $id @@ -142,9 +157,21 @@ public function getAssetByAlias(string $alias): Asset * Get an array of all modules * @return \Xibo\Entity\ModuleTemplate[] */ - public function getAll(): array + public function getAll(?string $ownership = null): array { - return $this->load(); + $templates = $this->load(); + + if ($ownership === null) { + return $templates; + } else { + $ownedBy = []; + foreach ($templates as $template) { + if ($ownership === $template->ownership) { + $ownedBy[] = $template; + } + } + return $ownedBy; + } } /** @@ -169,34 +196,98 @@ public function getAllAssets(): array private function load(): array { if ($this->templates === null) { - $this->getLog()->debug('Loading templates'); + $this->getLog()->debug('load: Loading templates'); - $files = array_merge( - glob(PROJECT_ROOT . '/modules/templates/*.xml'), - glob(PROJECT_ROOT . '/custom/modules/templates/*.xml') + $this->templates = array_merge( + $this->loadFolder( + PROJECT_ROOT . '/modules/templates/*.xml', + 'system', + ), + $this->loadFolder( + PROJECT_ROOT . '/custom/modules/templates/*.xml', + 'custom' + ), + $this->loadUserTemplates(), ); + } - foreach ($files as $file) { - // Create our module entity from this file - try { - $this->createMultiFromXml($file); - } catch (\Exception $exception) { - $this->getLog()->error('Unable to create template from ' - . basename($file) . ', skipping. e = ' . $exception->getMessage()); - } + return $this->templates; + } + + /** + * Load templates + * @return \Xibo\Entity\ModuleTemplate[] + */ + private function loadFolder(string $folder, string $ownership): array + { + $this->getLog()->debug('loadFolder: Loading templates from ' . $folder); + $templates = []; + + foreach (glob($folder) as $file) { + // Create our module entity from this file + try { + $templates = array_merge($templates, $this->createMultiFromXml($file, $ownership)); + } catch (\Exception $exception) { + $this->getLog()->error('Unable to create template from ' + . basename($file) . ', skipping. e = ' . $exception->getMessage()); } } - return $this->templates; + return $templates; + } + + /** + * Load user templates from the database. + * @return ModuleTemplate[] + */ + public function loadUserTemplates($id = null): array + { + $this->getLog()->debug('load: Loading user templates'); + + $templates = []; + $params = []; + + $sql = 'SELECT * FROM `module_templates`'; + if ($id !== null) { + $sql .= ' WHERE `id` = :id '; + $params['id'] = $id; + } + + foreach ($this->getStore()->select($sql, $params) as $row) { + $template = $this->createUserTemplate($row['xml']); + $template->id = intval($row['id']); + $template->templateId = $row['templateId']; + $template->dataType = $row['dataType']; + $templates[] = $template; + } + return $templates; + } + + /** + * Create a user template from an XML string + * @param string $xmlString + * @return ModuleTemplate + */ + public function createUserTemplate(string $xmlString): ModuleTemplate + { + $xml = new \DOMDocument(); + $xml->loadXML($xmlString); + + $template = $this->createFromXml($xml->documentElement, 'user', 'database'); + $template->setXml($xmlString); + return $template; } /** * Create multiple templates from XML * @param string $file - * @return void + * @param string $ownership + * @return ModuleTemplate[] */ - private function createMultiFromXml(string $file): void + private function createMultiFromXml(string $file, string $ownership): array { + $templates = []; + $xml = new \DOMDocument(); $xml->load($file); @@ -206,21 +297,26 @@ private function createMultiFromXml(string $file): void . ' templates in ' . $file); foreach ($node->childNodes as $childNode) { if ($childNode instanceof \DOMElement) { - $this->templates[] = $this->createFromXml($childNode); + $templates[] = $this->createFromXml($childNode, $ownership, $file); } } } } + + return $templates; } /** * @param \DOMElement $xml + * @param string $ownership + * @param string $file * @return \Xibo\Entity\ModuleTemplate */ - private function createFromXml(\DOMElement $xml): ModuleTemplate + private function createFromXml(\DOMElement $xml, string $ownership, string $file): ModuleTemplate { // TODO: cache this into Stash - $template = new ModuleTemplate($this->getStore(), $this->getLog(), $this->getDispatcher(), $this); + $template = new ModuleTemplate($this->getStore(), $this->getLog(), $this->getDispatcher(), $this, $file); + $template->ownership = $ownership; $template->templateId = $this->getFirstValueOrDefaultFromXmlNode($xml, 'id'); $template->type = $this->getFirstValueOrDefaultFromXmlNode($xml, 'type'); $template->dataType = $this->getFirstValueOrDefaultFromXmlNode($xml, 'dataType'); diff --git a/lib/Factory/UserGroupFactory.php b/lib/Factory/UserGroupFactory.php index e128b30fcf..081454ae32 100644 --- a/lib/Factory/UserGroupFactory.php +++ b/lib/Factory/UserGroupFactory.php @@ -803,6 +803,11 @@ public function getFeatures() 'group' => 'system', 'title' => __('Page which allows for Module Management for the platform') ], + 'developer.edit' => [ + 'feature' => 'developer.edit', + 'group' => 'system', + 'title' => __('Add/Edit custom modules and templates'), + ], 'transition.view' => [ 'feature' => 'transition.view', 'group' => 'system', diff --git a/lib/Widget/Render/WidgetHtmlRenderer.php b/lib/Widget/Render/WidgetHtmlRenderer.php index 925431cbe7..4747320664 100644 --- a/lib/Widget/Render/WidgetHtmlRenderer.php +++ b/lib/Widget/Render/WidgetHtmlRenderer.php @@ -269,7 +269,7 @@ public function decorateForPreview(Region $region, string $output, callable $url $output = str_replace('[[FontBundle]]', $urlFor('library.font.css', []), $output); } else if ($match === 'ViewPortWidth') { $output = str_replace('[[ViewPortWidth]]', $region->width, $output); - } else if (Str::startsWith($match, 'dataUrl')) { + } else if (Str::startsWith($match, 'dataUrl') || Str::startsWith($match, 'realTimeDataUrl')) { $value = explode('=', $match); $output = str_replace( '[[' . $match . ']]', @@ -335,6 +335,10 @@ public function decorateForPlayer( } else if ($match === 'ViewPortWidth') { // Player does this itself. continue; + } else if (Str::startsWith($match, 'realTimeDataUrl')) { + $value = explode('=', $match); + $replace = '/realtime?dataKey=' . $value[1]; + $output = str_replace('[[' . $match . ']]', $replace, $output); } else if (Str::startsWith($match, 'dataUrl')) { $value = explode('=', $match); $replace = $isSupportsDataUrl ? $value[1] . '.json' : 'null'; @@ -499,7 +503,11 @@ private function render( // Should we expect data? if ($module->isDataProviderExpected()) { - $widgetData['url'] = '[[dataUrl=' . $widget->widgetId . ']]'; + if ($module->type === 'realtime-data') { + $widgetData['url'] = '[[realTimeDataUrl=' . $widget->widgetId . ']]'; + } else { + $widgetData['url'] = '[[dataUrl=' . $widget->widgetId . ']]'; + } $widgetData['data'] = '[[data=' . $widget->widgetId . ']]'; } else { $widgetData['url'] = null; diff --git a/lib/routes-web.php b/lib/routes-web.php index 3cb33b770e..8d4398a7dc 100644 --- a/lib/routes-web.php +++ b/lib/routes-web.php @@ -419,6 +419,10 @@ $group->get('/dataset/form/cache/clear/{id}', ['\Xibo\Controller\DataSet', 'clearCacheForm'])->setName('dataSet.clear.cache.form'); $group->post('/dataset/cache/clear/{id}', ['\Xibo\Controller\DataSet', 'clearCache'])->setName('dataSet.clear.cache'); + $group->get('/dataset/dataConnector/{id}', ['\Xibo\Controller\DataSet', 'dataConnectorView'])->setName('dataSet.dataConnector.view'); + $group->get('/dataset/dataConnector/request/{id}', ['\Xibo\Controller\DataSet', 'dataConnectorRequest'])->setName('dataSet.dataConnector.request'); + $group->get('/dataset/dataConnector/test/{id}', ['\Xibo\Controller\DataSet', 'dataConnectorTest'])->setName('dataSet.dataConnector.test'); + // columns $group->get('/dataset/{id}/column/view', ['\Xibo\Controller\DataSetColumn','displayPage'])->setName('dataSet.column.view'); $group->get('/dataset/{id}/column/form/add', ['\Xibo\Controller\DataSetColumn','addForm'])->setName('dataSet.column.add.form'); @@ -566,6 +570,27 @@ ->setName('module.settings.form'); })->addMiddleware(new FeatureAuth($app->getContainer(), ['module.view'])); +// +// Developer +// +$app->group('', function (\Slim\Routing\RouteCollectorProxy $group) { + $group->get('/developer/template/view', ['\Xibo\Controller\Developer', 'displayTemplatePage']) + ->setName('developer.templates.view'); + $group->get('/developer/template', ['\Xibo\Controller\Developer', 'templateGrid']) + ->setName('developer.templates.search'); + + $group->get('/developer/template/form/add', ['\Xibo\Controller\Developer', 'templateAddForm']) + ->setName('developer.templates.form.add'); + + $group->get('/developer/template/form/edit/{id}', ['\Xibo\Controller\Developer', 'templateEditForm']) + ->setName('developer.templates.form.edit'); + + $group->post('/developer/template', ['\Xibo\Controller\Developer', 'templateAdd']) + ->setName('developer.templates.add'); + $group->put('/developer/template/{id}', ['\Xibo\Controller\Developer', 'templateEdit']) + ->setName('developer.templates.edit'); +})->addMiddleware(new FeatureAuth($app->getContainer(), ['developer.edit'])); + // // transition // diff --git a/lib/routes.php b/lib/routes.php index 0950c979b8..819734075a 100644 --- a/lib/routes.php +++ b/lib/routes.php @@ -467,6 +467,7 @@ $group->post('/dataset/import/{id}', ['\Xibo\Controller\DataSet','import'])->setName('dataSet.import'); $group->post('/dataset/importjson/{id}', ['\Xibo\Controller\DataSet','importJson'])->setName('dataSet.import.json'); $group->post('/dataset/remote/test', ['\Xibo\Controller\DataSet','testRemoteRequest'])->setName('dataSet.test.remote'); + $group->put('/dataset/dataConnector/{id}', ['\Xibo\Controller\DataSet','updateDataConnector'])->setName('dataSet.dataConnector.update'); $group->get('/dataset/export/csv/{id}', ['\Xibo\Controller\DataSet', 'exportToCsv'])->setName('dataSet.export.csv'); // Columns diff --git a/modules/realtime-dataset.xml b/modules/realtime-dataset.xml new file mode 100755 index 0000000000..f137329970 --- /dev/null +++ b/modules/realtime-dataset.xml @@ -0,0 +1,44 @@ + + + core-realtime-dataset + Realtime Data + Core + Display Realtime Data + fa fa-bolt + \Xibo\Widget\DataSetProvider + %dataSetId% + realtime-data + realtime + 1 + 1 + 1 + html + 60 + + + + DataSet + Please select the DataSet to use as a source of data for this template. + + + + diff --git a/views/authed-sidebar.twig b/views/authed-sidebar.twig index f0ccdae2d9..1df22fde4d 100644 --- a/views/authed-sidebar.twig +++ b/views/authed-sidebar.twig @@ -168,5 +168,14 @@ {% endif %} {% endif %} + + {% set countViewable = currentUser.featureEnabledCount(["developer.edit"]) %} + {% if countViewable > 0 %} + + + {% if currentUser.featureEnabled("developer.edit") %} + + {% endif %} + {% endif %}
diff --git a/views/authed-topbar.twig b/views/authed-topbar.twig index f953d700dc..c18c558eaf 100644 --- a/views/authed-topbar.twig +++ b/views/authed-topbar.twig @@ -326,4 +326,16 @@ {% endif %} {% endif %} - \ No newline at end of file + + {% set countViewable = currentUser.featureEnabledCount(["developer.edit"]) %} + {% if countViewable > 0 %} + + {% endif %} + diff --git a/views/dataset-data-connector-page.twig b/views/dataset-data-connector-page.twig new file mode 100644 index 0000000000..274504e93c --- /dev/null +++ b/views/dataset-data-connector-page.twig @@ -0,0 +1,202 @@ +{# +/* + * Copyright (C) 2023 Xibo Signage Ltd + * + * Xibo - Digital Signage - https://xibosignage.com + * + * This file is part of Xibo. + * + * Xibo is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * any later version. + * + * Xibo is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Xibo. If not, see . + */ +#} +{% extends "authed.twig" %} +{% import "inline.twig" as inline %} +{% import "forms.twig" as forms %} + +{% block title %}{% set dataSetName = dataSet.dataSet %}{% trans %}{{ dataSetName }} - Data Connector{% endtrans %} | {% endblock %} + +{% set hideNavigation = "1" %} + +{% block pageContent %} +
+ + +
+
+
+
+
+

{{ dataSetName }}

+
+
+
+
+
+
+
+
+ {{ "Data Connector JavaScript"|trans }} + + +
+
+
+
+
+ + {{ forms.button("Save"|trans, "submit", null, null, null, "btn-success") }} +
+
+
+
+
+ {{ inline.input("dataSetRealtimeTestParams", "Test Parameters"|trans) }} +
+
+
+
+

DataSet

+
+ + + {% for column in dataSet.getColumn() %} + + {% endfor %} + + + + + +
{{ column.heading }}Unmapped
+
+
+
+
+
+

Other data

+

+                            
+
+
+
+

Logs

+

+                            
+
+ +
+
+
+
+
+{% endblock %} + +{% block javaScript %} + +{% endblock %} diff --git a/views/dataset-data-connector-test-page.twig b/views/dataset-data-connector-test-page.twig new file mode 100644 index 0000000000..fb237c77bc --- /dev/null +++ b/views/dataset-data-connector-test-page.twig @@ -0,0 +1,122 @@ +{# +/* + * Copyright (C) 2023 Xibo Signage Ltd + * + * Xibo - Digital Signage - https://xibosignage.com + * + * This file is part of Xibo. + * + * Xibo is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * any later version. + * + * Xibo is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Xibo. If not, see . + */ +#} + + + + Data Connector Test + + + + + + + + + + + + + diff --git a/views/dataset-form-add.twig b/views/dataset-form-add.twig index 8e244d76c1..51a7898517 100644 --- a/views/dataset-form-add.twig +++ b/views/dataset-form-add.twig @@ -44,7 +44,6 @@ -
@@ -293,19 +292,6 @@ ] %} {{ forms.dropdown("limitPolicy", "single", title, "", options, "typeid", "type", helpText) }}
- -
-
-
- {{ "Data Connector JavaScript"|trans }} - - -
-
-
-
-
-
diff --git a/views/dataset-form-edit.twig b/views/dataset-form-edit.twig index 5f8f8982b7..bf760f8bfb 100644 --- a/views/dataset-form-edit.twig +++ b/views/dataset-form-edit.twig @@ -1,8 +1,8 @@ {# -/** - * Copyright (C) 2020 Xibo Signage Ltd +/* + * Copyright (C) 2023 Xibo Signage Ltd * - * Xibo - Digital Signage - http://www.xibo.org.uk + * Xibo - Digital Signage - https://xibosignage.com * * This file is part of Xibo. * @@ -44,7 +44,6 @@ -
@@ -302,19 +301,6 @@ ] %} {{ forms.dropdown("limitPolicy", "single", title, dataSet.limitPolicy, options, "typeid", "type", helpText) }} - -
-
-
- {{ "Data Connector JavaScript"|trans }} - - -
-
-
-
-
-
diff --git a/views/dataset-page.twig b/views/dataset-page.twig index 0b1a5ec040..3209f55717 100644 --- a/views/dataset-page.twig +++ b/views/dataset-page.twig @@ -313,7 +313,7 @@ function dataSetFormOpen(dialog) { - // Bind the test button + // Bind the remote dataset test button $(dialog).find("#dataSetRemoteTestButton").on('click', function() { var $form = $(dialog).find("form"); XiboRemoteRequest("{{ url_for("dataSet.test.remote") }}", $form.serializeObject(), function(response) { @@ -343,12 +343,6 @@ $(dialog).find('#sourceId').on('change', function() { onSourceFieldChanged(dialog); }); - - onRealTimeFieldChanged(dialog); - - $(dialog).find("#isRealTime").on('change', function() { - onRealTimeFieldChanged(dialog); - }); } function onRemoteFieldChanged(dialog) { @@ -362,17 +356,6 @@ } } - function onRealTimeFieldChanged(dialog) { - var isRemote = $(dialog).find("#isRealTime").is(":checked"); - var $remoteTabs = $(dialog).find(".tabForRealTimeDataSet"); - - if (isRemote) { - $remoteTabs.removeClass("d-none"); - } else { - $remoteTabs.addClass("d-none"); - } - } - function onAuthenticationFieldChanged(dialog) { var authentication = $(dialog).find("#authentication").val(); var $authFieldUserName = $(dialog).find(".auth-field-username"); diff --git a/views/developer-template-form-add.twig b/views/developer-template-form-add.twig new file mode 100644 index 0000000000..a9667f7233 --- /dev/null +++ b/views/developer-template-form-add.twig @@ -0,0 +1,57 @@ +{# +/* + * Copyright (C) 2023 Xibo Signage Ltd + * + * Xibo - Digital Signage - https://xibosignage.com + * + * This file is part of Xibo. + * + * Xibo is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * any later version. + * + * Xibo is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Xibo. If not, see . + */ +#} + +{% extends "form-base.twig" %} +{% import "forms.twig" as forms %} + +{% block formTitle %} + {% trans "Add Module Template" %} +{% endblock %} + +{% block formButtons %} + {% trans "Cancel" %}, XiboDialogClose() + {% trans "Save" %}, $("#form-module-template").submit(); +{% endblock %} + +{% block formHtml %} +
+
+
+ +
+
+ {{ "Module Template XML"|trans }} + + +
+
+
+
+
+
+
+
+{% endblock %} diff --git a/views/developer-template-form-edit.twig b/views/developer-template-form-edit.twig new file mode 100644 index 0000000000..b3a2f2c4ef --- /dev/null +++ b/views/developer-template-form-edit.twig @@ -0,0 +1,59 @@ +{# +/* + * Copyright (C) 2023 Xibo Signage Ltd + * + * Xibo - Digital Signage - https://xibosignage.com + * + * This file is part of Xibo. + * + * Xibo is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * any later version. + * + * Xibo is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Xibo. If not, see . + */ +#} + +{% extends "form-base.twig" %} +{% import "forms.twig" as forms %} + +{% block formTitle %} + {% trans "Add Module Template" %} +{% endblock %} + +{% block formButtons %} + {% trans "Cancel" %}, XiboDialogClose() + {% trans "Save" %}, $("#form-module-template").submit(); +{% endblock %} + +{% block formHtml %} +
+
+
+ +
+
+ {{ "Module Template XML"|trans }} + + +
+
+
+
+
+ + {{ forms.checkbox("isInvalidateWidget", "Invalidate any widgets using this template"|trans) }} +
+
+
+{% endblock %} diff --git a/views/developer-template-page.twig b/views/developer-template-page.twig new file mode 100644 index 0000000000..7c0640e9fa --- /dev/null +++ b/views/developer-template-page.twig @@ -0,0 +1,121 @@ +{# +/* + * Copyright (C) 2023 Xibo Signage Ltd + * + * Xibo - Digital Signage - https://xibosignage.com + * + * This file is part of Xibo. + * + * Xibo is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * any later version. + * + * Xibo is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Xibo. If not, see . + */ +#} +{% extends "authed.twig" %} +{% import "inline.twig" as inline %} + +{% block title %}{{ "Module Templates"|trans }} | {% endblock %} + +{% block actionMenu %} +
+ + +
+{% endblock %} + +{% block pageContent %} +
+
{% trans "Modules" %}
+
+
+
+
+
+ {% set title %}{% trans "Name" %}{% endset %} + {{ inline.input('name', title) }} +
+
+
+
+ + + + + + + + + + + + + + +
{% trans "ID" %}{% trans "Template ID" %}{% trans "Data Type" %}{% trans "Title" %}{% trans "Type" %}
+
+
+
+
+{% endblock %} + +{% block javaScript %} + +{% endblock %} From 15b5e55b48dc1f2222e7588f8142b926cfc31fb7 Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Thu, 21 Dec 2023 15:16:14 +0000 Subject: [PATCH 005/203] Real time data pt5 (#2280) Part 5: improve data connector "other data" view and use broadcast channel to test widgets in layout editor. --- lib/Controller/DataSet.php | 12 ++++++------ lib/Widget/Render/WidgetHtmlRenderer.php | 19 ++++++------------- modules/src/player-bundle.js | 4 ++-- package-lock.json | 4 ++-- package.json | 2 +- views/dataset-data-connector-page.twig | 20 ++++++++++++++++---- views/dataset-data-connector-test-page.twig | 9 ++++----- views/developer-template-form-edit.twig | 2 +- 8 files changed, 38 insertions(+), 34 deletions(-) diff --git a/lib/Controller/DataSet.php b/lib/Controller/DataSet.php index 02d46f7714..ad644f33f6 100644 --- a/lib/Controller/DataSet.php +++ b/lib/Controller/DataSet.php @@ -1697,10 +1697,10 @@ public function clearCache(Request $request, Response $response, $id) * @param Request $request * @param Response $response * @param $id - * @return \Psr\Http\Message\ResponseInterface|Response + * @return Response * @throws GeneralException */ - public function dataConnectorView(Request $request, Response $response, $id) + public function dataConnectorView(Request $request, Response $response, $id): Response { $dataSet = $this->dataSetFactory->getById($id); @@ -1724,10 +1724,10 @@ public function dataConnectorView(Request $request, Response $response, $id) * @param Request $request * @param Response $response * @param $id - * @return \Psr\Http\Message\ResponseInterface|Response + * @return Response * @throws GeneralException */ - public function dataConnectorTest(Request $request, Response $response, $id) + public function dataConnectorTest(Request $request, Response $response, $id): Response { $dataSet = $this->dataSetFactory->getById($id); @@ -1751,10 +1751,10 @@ public function dataConnectorTest(Request $request, Response $response, $id) * @param Request $request * @param Response $response * @param $id - * @return \Psr\Http\Message\ResponseInterface|Response + * @return Response * @throws GeneralException */ - public function dataConnectorRequest(Request $request, Response $response, $id) + public function dataConnectorRequest(Request $request, Response $response, $id): Response { $dataSet = $this->dataSetFactory->getById($id); diff --git a/lib/Widget/Render/WidgetHtmlRenderer.php b/lib/Widget/Render/WidgetHtmlRenderer.php index 4747320664..6bbc8c9e86 100644 --- a/lib/Widget/Render/WidgetHtmlRenderer.php +++ b/lib/Widget/Render/WidgetHtmlRenderer.php @@ -269,7 +269,7 @@ public function decorateForPreview(Region $region, string $output, callable $url $output = str_replace('[[FontBundle]]', $urlFor('library.font.css', []), $output); } else if ($match === 'ViewPortWidth') { $output = str_replace('[[ViewPortWidth]]', $region->width, $output); - } else if (Str::startsWith($match, 'dataUrl') || Str::startsWith($match, 'realTimeDataUrl')) { + } else if (Str::startsWith($match, 'dataUrl')) { $value = explode('=', $match); $output = str_replace( '[[' . $match . ']]', @@ -335,10 +335,6 @@ public function decorateForPlayer( } else if ($match === 'ViewPortWidth') { // Player does this itself. continue; - } else if (Str::startsWith($match, 'realTimeDataUrl')) { - $value = explode('=', $match); - $replace = '/realtime?dataKey=' . $value[1]; - $output = str_replace('[[' . $match . ']]', $replace, $output); } else if (Str::startsWith($match, 'dataUrl')) { $value = explode('=', $match); $replace = $isSupportsDataUrl ? $value[1] . '.json' : 'null'; @@ -472,11 +468,9 @@ private function render( $widget->isValid = 0; } + // Parse out some common properties. $moduleLanguage = null; - $translator = null; - // Check if a language property is defined against the module - // Note: We are using the language defined against the module and not from the module template foreach ($module->properties as $property) { if ($property->type === 'languageSelector' && !empty($property->value)) { $moduleLanguage = $property->value; @@ -484,6 +478,9 @@ private function render( } } + // Configure a translator for the module + // Note: We are using the language defined against the module and not from the module template + $translator = null; if ($moduleLanguage !== null) { $translator = Translate::getTranslationsFromLocale($moduleLanguage); } @@ -503,11 +500,7 @@ private function render( // Should we expect data? if ($module->isDataProviderExpected()) { - if ($module->type === 'realtime-data') { - $widgetData['url'] = '[[realTimeDataUrl=' . $widget->widgetId . ']]'; - } else { - $widgetData['url'] = '[[dataUrl=' . $widget->widgetId . ']]'; - } + $widgetData['url'] = '[[dataUrl=' . $widget->widgetId . ']]'; $widgetData['data'] = '[[data=' . $widget->widgetId . ']]'; } else { $widgetData['url'] = null; diff --git a/modules/src/player-bundle.js b/modules/src/player-bundle.js index 67cca92685..ab480c9c60 100644 --- a/modules/src/player-bundle.js +++ b/modules/src/player-bundle.js @@ -23,7 +23,7 @@ const globalThis = require('globalthis/polyfill')(); import 'core-js/stable/url-search-params'; // Our own imports -import 'xibo-interactive-control/dist/xibo-interactive-control.min.js'; +import 'xibo-interactive-control/src/xibo-interactive-control.js'; import './xibo-calendar-render'; import './xibo-countdown-render'; import './xibo-finance-render'; @@ -40,10 +40,10 @@ import './xibo-webpage-render'; import './xibo-worldclock-render'; import './xibo-elements-render'; import './editor-render'; +import './player'; // Import PlayerHelper window.PlayerHelper = require('../../ui/src/helpers/player-helper.js'); -import './player'; window.jQuery = window.$ = require('jquery'); require('babel-polyfill'); diff --git a/package-lock.json b/package-lock.json index 4a49a6d9f9..8bbee85b14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12455,8 +12455,8 @@ } }, "xibo-interactive-control": { - "version": "git+https://github.com/xibosignage/xibo-interactive-control.git#e2a65c1beb28d7e115139acc6cff7d2803969d64", - "from": "git+https://github.com/xibosignage/xibo-interactive-control.git#e2a65c1beb28d7e115139acc6cff7d2803969d64" + "version": "git+https://github.com/xibosignage/xibo-interactive-control.git#c66f0dfafff9bd84715dc3fe031239cbe78b2829", + "from": "git+https://github.com/xibosignage/xibo-interactive-control.git#c66f0dfafff9bd84715dc3fe031239cbe78b2829" }, "xtend": { "version": "4.0.2", diff --git a/package.json b/package.json index 9f89c4f8bf..caa3d7b957 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,6 @@ "serialize-javascript": "^3.1.0", "toastr": "~2.1.4", "underscore": "^1.12.1", - "xibo-interactive-control": "git+https://github.com/xibosignage/xibo-interactive-control.git#e2a65c1beb28d7e115139acc6cff7d2803969d64" + "xibo-interactive-control": "git+https://github.com/xibosignage/xibo-interactive-control.git#c66f0dfafff9bd84715dc3fe031239cbe78b2829" } } diff --git a/views/dataset-data-connector-page.twig b/views/dataset-data-connector-page.twig index 274504e93c..458dc24956 100644 --- a/views/dataset-data-connector-page.twig +++ b/views/dataset-data-connector-page.twig @@ -120,6 +120,10 @@ const $mainData = $('#dataconnector-main-data'); const $otherData = $('#dataconnector-other-data'); const $logs = $('#dataconnector-logs'); + let otherData = {}; + + // Set up a channel which will broadcast data + const channel = new BroadcastChannel('xiboDC'); // Set our script params from local storage if we have them $scriptParams.val(localStorage.getItem('dataSetRealtimeTestParams')); @@ -133,10 +137,11 @@ console.log('Script loaded'); $script.find('iframe')[0].contentWindow.xiboDC.initialise({{ dataSet.dataSetId }}, $scriptParams.val()); } else if (type === 'log') { - $logs.append(data).append('\n'); + $logs.prepend('[' + new Date().getTime() + '] ' + data + '\n'); } else if (type === 'set') { // Update the table - if (data.dataSetId == '{{ dataSet.dataSetId }}' && Array.isArray(data.data)) { + // if the dataKey matches my connector's DataSetId, then render out a table + if (data.dataKey == '{{ dataSet.dataSetId }}' && Array.isArray(data.data)) { const $tableBody = $mainData.find('tbody'); $tableBody.find('tr').remove(); $.each(data.data, function (rowIndex, row) { @@ -159,11 +164,18 @@ }); }); } else { - $otherData.html(JSON.stringify(data)); + // Grab the existing "other data" and see if there is a matching key. + otherData[data.dataKey] = data.data; + $otherData.html(JSON.stringify(otherData, null, 4)); } + + // Broadcast to interested parties. + channel.postMessage({type: "xiboDC_data", dataKey: data.dataKey, data: data.data}); } else if (type === 'notify') { // Log - $logs.append('Notify for ' + data).append('\n'); + $logs.prepend('[' + new Date().getTime() + '] Notify for ' + data + '\n'); + + channel.postMessage({type: "xiboDC_notify", dataKey: data}); } } diff --git a/views/dataset-data-connector-test-page.twig b/views/dataset-data-connector-test-page.twig index fb237c77bc..f9a964299b 100644 --- a/views/dataset-data-connector-test-page.twig +++ b/views/dataset-data-connector-test-page.twig @@ -65,7 +65,7 @@ setData: function(dataKey, data, {done, error} = {}) { // Persist the data we've been given window.parent.receiveData('set', { - dataSetId: dataKey, + dataKey: dataKey, data: data }); if (typeof (done) == 'function') { @@ -75,12 +75,11 @@ /** * Notify main application that we have new data. Called from data collector. - * @param {string} dataSetId - The id of the dataset - * @param {string} widgetId - Optional. Widget id to notify. If omitted, all widgets will be notified. + * @param {string} dataKey - The key of the data that has been changed. */ - notifyHost: function(dataSetId, widgetId) { + notifyHost: function(dataKey) { // Update the table. - window.parent.receiveData('notify', dataSetId); + window.parent.receiveData('notify', dataKey); }, /** diff --git a/views/developer-template-form-edit.twig b/views/developer-template-form-edit.twig index b3a2f2c4ef..76a47fc4a8 100644 --- a/views/developer-template-form-edit.twig +++ b/views/developer-template-form-edit.twig @@ -25,7 +25,7 @@ {% import "forms.twig" as forms %} {% block formTitle %} - {% trans "Add Module Template" %} + {% trans "Edit Module Template" %} {% endblock %} {% block formButtons %} From b9e64eb4b270a075d787f5cd7079ae863ece3eb5 Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Fri, 22 Dec 2023 09:39:42 +0000 Subject: [PATCH 006/203] Part 6: xiboIC fixes (#2281) Update xiboIC --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8bbee85b14..bab62f9f2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12455,8 +12455,8 @@ } }, "xibo-interactive-control": { - "version": "git+https://github.com/xibosignage/xibo-interactive-control.git#c66f0dfafff9bd84715dc3fe031239cbe78b2829", - "from": "git+https://github.com/xibosignage/xibo-interactive-control.git#c66f0dfafff9bd84715dc3fe031239cbe78b2829" + "version": "git+https://github.com/xibosignage/xibo-interactive-control.git#be2320a5a7ae13389fd0bf5cfc285d583f94e558", + "from": "git+https://github.com/xibosignage/xibo-interactive-control.git#be2320a5a7ae13389fd0bf5cfc285d583f94e558" }, "xtend": { "version": "4.0.2", diff --git a/package.json b/package.json index caa3d7b957..cf6ba870d9 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,6 @@ "serialize-javascript": "^3.1.0", "toastr": "~2.1.4", "underscore": "^1.12.1", - "xibo-interactive-control": "git+https://github.com/xibosignage/xibo-interactive-control.git#c66f0dfafff9bd84715dc3fe031239cbe78b2829" + "xibo-interactive-control": "git+https://github.com/xibosignage/xibo-interactive-control.git#be2320a5a7ae13389fd0bf5cfc285d583f94e558" } } From d583103e2a3af8c9af8fafdadd56edf2ab163d1f Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Fri, 22 Dec 2023 14:38:52 +0000 Subject: [PATCH 007/203] Part 7: xiboIC fixes and pass data in CMS as string. (#2283) --- package-lock.json | 4 ++-- package.json | 2 +- views/dataset-data-connector-page.twig | 4 ++++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index bab62f9f2c..4a3a2abc2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12455,8 +12455,8 @@ } }, "xibo-interactive-control": { - "version": "git+https://github.com/xibosignage/xibo-interactive-control.git#be2320a5a7ae13389fd0bf5cfc285d583f94e558", - "from": "git+https://github.com/xibosignage/xibo-interactive-control.git#be2320a5a7ae13389fd0bf5cfc285d583f94e558" + "version": "git+https://github.com/xibosignage/xibo-interactive-control.git#6bd108730ed670616c89356769f36a05fd605518", + "from": "git+https://github.com/xibosignage/xibo-interactive-control.git#6bd108730ed670616c89356769f36a05fd605518" }, "xtend": { "version": "4.0.2", diff --git a/package.json b/package.json index cf6ba870d9..d6e35e55bf 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,6 @@ "serialize-javascript": "^3.1.0", "toastr": "~2.1.4", "underscore": "^1.12.1", - "xibo-interactive-control": "git+https://github.com/xibosignage/xibo-interactive-control.git#be2320a5a7ae13389fd0bf5cfc285d583f94e558" + "xibo-interactive-control": "git+https://github.com/xibosignage/xibo-interactive-control.git#6bd108730ed670616c89356769f36a05fd605518" } } diff --git a/views/dataset-data-connector-page.twig b/views/dataset-data-connector-page.twig index 458dc24956..6d755bde73 100644 --- a/views/dataset-data-connector-page.twig +++ b/views/dataset-data-connector-page.twig @@ -170,6 +170,10 @@ } // Broadcast to interested parties. + // JSON.stringify if the data is an array or object, otherwise pass straight through. + if (typeof data.data !== 'string') { + data.data = JSON.stringify(data.data); + } channel.postMessage({type: "xiboDC_data", dataKey: data.dataKey, data: data.data}); } else if (type === 'notify') { // Log From dcf847b7f0a51e6731d98006bb6c8f432ae18470 Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Fri, 22 Dec 2023 17:14:34 +0000 Subject: [PATCH 008/203] Part 8: Expect setData to be a string always. (#2284) --- views/dataset-data-connector-page.twig | 14 +++++++------- views/dataset-data-connector-test-page.twig | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/views/dataset-data-connector-page.twig b/views/dataset-data-connector-page.twig index 6d755bde73..e244bea6ea 100644 --- a/views/dataset-data-connector-page.twig +++ b/views/dataset-data-connector-page.twig @@ -139,9 +139,12 @@ } else if (type === 'log') { $logs.prepend('[' + new Date().getTime() + '] ' + data + '\n'); } else if (type === 'set') { + // Data is always set as a string + const events = JSON.parse(data.data); + // Update the table // if the dataKey matches my connector's DataSetId, then render out a table - if (data.dataKey == '{{ dataSet.dataSetId }}' && Array.isArray(data.data)) { + if (data.dataKey == '{{ dataSet.dataSetId }}' && Array.isArray(events)) { const $tableBody = $mainData.find('tbody'); $tableBody.find('tr').remove(); $.each(data.data, function (rowIndex, row) { @@ -165,16 +168,13 @@ }); } else { // Grab the existing "other data" and see if there is a matching key. - otherData[data.dataKey] = data.data; + otherData[data.dataKey] = events; $otherData.html(JSON.stringify(otherData, null, 4)); } // Broadcast to interested parties. - // JSON.stringify if the data is an array or object, otherwise pass straight through. - if (typeof data.data !== 'string') { - data.data = JSON.stringify(data.data); - } - channel.postMessage({type: "xiboDC_data", dataKey: data.dataKey, data: data.data}); + // Use the original data.data (which is a string) + channel.postMessage({type: 'xiboDC_data', dataKey: data.dataKey, data: data.data}); } else if (type === 'notify') { // Log $logs.prepend('[' + new Date().getTime() + '] Notify for ' + data + '\n'); diff --git a/views/dataset-data-connector-test-page.twig b/views/dataset-data-connector-test-page.twig index f9a964299b..bbfe4b83ee 100644 --- a/views/dataset-data-connector-test-page.twig +++ b/views/dataset-data-connector-test-page.twig @@ -56,8 +56,8 @@ /** * Set the realtime into the player. Called from Data Connector. - * @param {string} dataKey The id of the dataset - * @param {String} data The data for the dataset as string + * @param {string} dataKey A dataKey to store this data + * @param {String} data The data as string * @param {Object} options - Request options * @param {callback} options.done Optional * @param {callback} options.error Optional From ba567b66a0bc8cdee7bf91a704b4b7998c14edcd Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Fri, 22 Dec 2023 17:47:31 +0000 Subject: [PATCH 009/203] Part 10: Fix issue setting other data (#2285) --- views/dataset-data-connector-page.twig | 56 ++++++++++++++------------ 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/views/dataset-data-connector-page.twig b/views/dataset-data-connector-page.twig index e244bea6ea..a0c3932ff4 100644 --- a/views/dataset-data-connector-page.twig +++ b/views/dataset-data-connector-page.twig @@ -139,36 +139,42 @@ } else if (type === 'log') { $logs.prepend('[' + new Date().getTime() + '] ' + data + '\n'); } else if (type === 'set') { - // Data is always set as a string - const events = JSON.parse(data.data); - // Update the table // if the dataKey matches my connector's DataSetId, then render out a table - if (data.dataKey == '{{ dataSet.dataSetId }}' && Array.isArray(events)) { - const $tableBody = $mainData.find('tbody'); - $tableBody.find('tr').remove(); - $.each(data.data, function (rowIndex, row) { - // Make a new row - let html = ''; - {% for column in dataSet.getColumn() %} - html += ''; - {% endfor %} - html += ''; - const $newRow = $(html); - $tableBody.append($newRow); - - // Do we have a column for this item - $.each(row, function(colIndex, col) { - if ($newRow.find('td[data-id=' + colIndex).length > 0) { - $newRow.find('td[data-id=' + colIndex).append(row[colIndex]); - } else { - $newRow.find('td[data-id=unmatched').append(colIndex + ': ' + row[colIndex] + '
'); - } + if (data.dataKey == '{{ dataSet.dataSetId }}') { + // Data is always set as a string + const events = JSON.parse(data.data); + + if (Array.isArray(events)) { + const $tableBody = $mainData.find('tbody'); + $tableBody.find('tr').remove(); + $.each(events, function (rowIndex, row) { + // Make a new row + let html = ''; + {% for column in dataSet.getColumn() %} + html += ''; + {% endfor %} + html += ''; + const $newRow = $(html); + $tableBody.append($newRow); + + // Do we have a column for this item + $.each(row, function (colIndex, col) { + if ($newRow.find('td[data-id=' + colIndex).length > 0) { + $newRow.find('td[data-id=' + colIndex).append(row[colIndex]); + } else { + $newRow.find('td[data-id=unmatched').append(colIndex + ': ' + row[colIndex] + '
'); + } + }); }); - }); + } else { + // Treat it as other data. + otherData[data.dataKey] = data.data; + $otherData.html(JSON.stringify(otherData, null, 4)); + } } else { // Grab the existing "other data" and see if there is a matching key. - otherData[data.dataKey] = events; + otherData[data.dataKey] = data.data; $otherData.html(JSON.stringify(otherData, null, 4)); } From ab663388b107f290c6892082100a8b55db02627f Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Thu, 18 Jan 2024 16:39:42 +0000 Subject: [PATCH 010/203] Part 10: Fix issue setting other data (#2315) From d6af9c5c04a94f1d1fc39d476a1bfb14a75aef91 Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Thu, 18 Jan 2024 16:40:48 +0000 Subject: [PATCH 011/203] Schedule criteria part 1 (#2316) * Tidy up data connector user interface and implement setCriteria method/view. * Fix for schedule form so that new criteria doesn't get set to 3 items. --- ui/src/core/xibo-calendar.js | 7 +- views/dataset-data-connector-page.twig | 115 ++++++++++++++------ views/dataset-data-connector-test-page.twig | 21 +++- 3 files changed, 104 insertions(+), 39 deletions(-) diff --git a/ui/src/core/xibo-calendar.js b/ui/src/core/xibo-calendar.js index 8c6ca456a4..4ca1461f10 100644 --- a/ui/src/core/xibo-calendar.js +++ b/ui/src/core/xibo-calendar.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023 Xibo Signage Ltd + * Copyright (C) 2024 Xibo Signage Ltd * * Xibo - Digital Signage - https://xibosignage.com * @@ -1724,11 +1724,12 @@ const configureCriteriaFields = function(dialog) { Handlebars.compile($('#templateScheduleCriteriaFields').html()); // Existing criteria? - if ($fields.data('criteria').length >= 0) { + const existingCriteria = $fields.data('criteria'); + if (existingCriteria && existingCriteria.length >= 0) { // Yes there are existing criteria // Go through each one and add a field row to the form. let i = 0; - $.each($fields.data('criteria'), function(index, element) { + $.each(existingCriteria, function(index, element) { i++; element.isAdd = false; element.i = i; diff --git a/views/dataset-data-connector-page.twig b/views/dataset-data-connector-page.twig index a0c3932ff4..802ccc9dd2 100644 --- a/views/dataset-data-connector-page.twig +++ b/views/dataset-data-connector-page.twig @@ -1,6 +1,6 @@ {# /* - * Copyright (C) 2023 Xibo Signage Ltd + * Copyright (C) 2024 Xibo Signage Ltd * * Xibo - Digital Signage - https://xibosignage.com * @@ -71,44 +71,82 @@
- {{ inline.input("dataSetRealtimeTestParams", "Test Parameters"|trans) }} -
-
-
-
-

DataSet

-
- - - {% for column in dataSet.getColumn() %} - - {% endfor %} - - - - - -
{{ column.heading }}Unmapped
+ +
+
+ {{ inline.message("You can test passing parameters that would otherwise be set when this Data Connector is scheduled."|trans, "alert alert-info") }} + + {{ inline.input("dataSetRealtimeTestParams", "Test Parameters"|trans) }} +
+
+

+                                    
+
+
+ + + {% for column in dataSet.getColumn() %} + + {% endfor %} + + + + + +
{{ column.heading }}Unmapped
+
+
+
+

+                                    
+
+
+ + + + + + + + + +
{{ "Metric"|trans }}{{ "Value"|trans }}TTL
+
+
-
-
-

Other data

-

-                            
-
-
-
-

Logs

-

-                            
-
-
+ {% endblock %} @@ -119,8 +157,10 @@ const $scriptParams = $('#dataSetRealtimeTestParams'); const $mainData = $('#dataconnector-main-data'); const $otherData = $('#dataconnector-other-data'); + const $scheduleCriteria = $('#dataconnector-schedule-criteria'); const $logs = $('#dataconnector-logs'); let otherData = {}; + let criteria = {}; // Set up a channel which will broadcast data const channel = new BroadcastChannel('xiboDC'); @@ -137,7 +177,7 @@ console.log('Script loaded'); $script.find('iframe')[0].contentWindow.xiboDC.initialise({{ dataSet.dataSetId }}, $scriptParams.val()); } else if (type === 'log') { - $logs.prepend('[' + new Date().getTime() + '] ' + data + '\n'); + $logs.prepend('[' + moment().format('YY-MM-DD HH:mm:ss') + '] ' + data + '\n'); } else if (type === 'set') { // Update the table // if the dataKey matches my connector's DataSetId, then render out a table @@ -183,9 +223,16 @@ channel.postMessage({type: 'xiboDC_data', dataKey: data.dataKey, data: data.data}); } else if (type === 'notify') { // Log - $logs.prepend('[' + new Date().getTime() + '] Notify for ' + data + '\n'); + $logs.prepend('[' + moment().format('YY-MM-DD HH:mm:ss') + '] Notify for ' + data + '\n'); channel.postMessage({type: "xiboDC_notify", dataKey: data}); + } else if (type === 'criteria') { + // Schedule criteria, update in the table. + criteria[data.dataKey] = data.data; + const $tableBody = $scheduleCriteria.find('tbody'); + $.each(criteria, function (key, value) { + $tableBody.append('' + key + '' + value.value + '' + value.ttl + ''); + }); } } diff --git a/views/dataset-data-connector-test-page.twig b/views/dataset-data-connector-test-page.twig index bbfe4b83ee..fd0ea473a2 100644 --- a/views/dataset-data-connector-test-page.twig +++ b/views/dataset-data-connector-test-page.twig @@ -1,6 +1,6 @@ {# /* - * Copyright (C) 2023 Xibo Signage Ltd + * Copyright (C) 2024 Xibo Signage Ltd * * Xibo - Digital Signage - https://xibosignage.com * @@ -95,7 +95,24 @@ */ makeRequest: function(path, {type, headers, data, done, error} = {}) { window.parent.makeRequest(path, {type, headers, data, done, error}); - } + }, + + /** + * Set Schedule Criteria + * @param {string} metric The Metric Name + * @param {string} value The Value + * @param {int} ttl A TTL in seconds + */ + setCriteria: function(metric, value, ttl) { + window.parent.receiveData('criteria', { + dataKey: metric, + data: { + metric: metric, + value: value || null, + ttl: ttl, + } + }); + }, } return mainLib; })(); From fcbc43488b2c543ff28495543958751f9882423d Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Wed, 24 Jan 2024 14:50:53 +0000 Subject: [PATCH 012/203] Module Template editing in the CMS (#2320) * Module Template editing in the CMS - WIP. * Notify dataset for connector updates. --- lib/Controller/DataSet.php | 5 +- lib/Controller/Developer.php | 160 +++++++++++++++++-- lib/Entity/ModuleTemplate.php | 43 ++++- lib/Factory/ModuleTemplateFactory.php | 4 +- lib/Service/DisplayNotifyService.php | 31 +++- lib/routes-web.php | 5 +- views/dataset-data-connector-page.twig | 4 +- views/developer-template-edit-page.twig | 204 ++++++++++++++++++++++++ views/developer-template-form-add.twig | 15 +- views/developer-template-form-edit.twig | 59 ------- 10 files changed, 433 insertions(+), 97 deletions(-) create mode 100644 views/developer-template-edit-page.twig delete mode 100644 views/developer-template-form-edit.twig diff --git a/lib/Controller/DataSet.php b/lib/Controller/DataSet.php index ad644f33f6..956cd10650 100644 --- a/lib/Controller/DataSet.php +++ b/lib/Controller/DataSet.php @@ -1,6 +1,6 @@ isRealTime === 1) { // Set the script. $dataSet->saveScript($sanitizedParams->getParam('dataConnectorScript')); - - // TODO: we should notify displays which have this scheduled to them as data connectors. + $dataSet->notify(); } else { throw new InvalidArgumentException(__('This DataSet does not have a data connector'), 'isRealTime'); } diff --git a/lib/Controller/Developer.php b/lib/Controller/Developer.php index a7021d540e..39c365d1f8 100644 --- a/lib/Controller/Developer.php +++ b/lib/Controller/Developer.php @@ -1,6 +1,6 @@ buttons[] = [ 'id' => 'template_button_edit', - 'url' => $this->urlFor($request, 'developer.templates.form.edit', ['id' => $template->id]), - 'text' => __('Edit') + 'url' => $this->urlFor($request, 'developer.templates.view.edit', ['id' => $template->id]), + 'text' => __('Edit'), + 'class' => 'XiboRedirectButton', ]; } @@ -100,25 +101,29 @@ public function templateAddForm(Request $request, Response $response): Response } /** - * Shows an edit form for a module template + * Display the module template page * @param Request $request * @param Response $response - * @param $id + * @param mixed $id The template ID to edit. * @return \Slim\Http\Response * @throws \Xibo\Support\Exception\GeneralException */ - public function templateEditForm(Request $request, Response $response, $id): Response + public function displayTemplateEditPage(Request $request, Response $response, $id): Response { $template = $this->moduleTemplateFactory->getUserTemplateById($id); if ($template->ownership !== 'user') { throw new AccessDeniedException(); } - $this->getState()->template = 'developer-template-form-edit'; + //TODO: temporary extraction of properties XML (should be a form field per property instead) + $doc = $template->getDocument(); + /** @var \DOMElement $properties */ + $properties = $doc->getElementsByTagName('properties')[0]; + + $this->getState()->template = 'developer-template-edit-page'; $this->getState()->setData([ - 'id' => $id, 'template' => $template, - 'xml' => $template->getXml(), + 'properties' => $doc->saveXML($properties), ]); return $this->render($request, $response); @@ -135,11 +140,24 @@ public function templateAdd(Request $request, Response $response): Response { // When adding a template we just save the XML $params = $this->getSanitizer($request->getParams()); - $xml = $params->getParam('xml', ['throw' => function () { - throw new InvalidArgumentException(__('Please supply the templates XML'), 'xml'); + $templateId = $params->getString('templateId', ['throw' => function () { + throw new InvalidArgumentException(__('Please supply a unique template ID'), 'templateId'); }]); + $dataType = $params->getString('dataType', ['throw' => function () { + throw new InvalidArgumentException(__('Please supply a data type'), 'dataType'); + }]); + + // The most basic template possible. + $template = $this->moduleTemplateFactory->createUserTemplate(' + '); - $template = $this->moduleTemplateFactory->createUserTemplate($xml); $template->save(); $this->getState()->hydrate([ @@ -164,18 +182,128 @@ public function templateEdit(Request $request, Response $response, $id): Respons $template = $this->moduleTemplateFactory->getUserTemplateById($id); $params = $this->getSanitizer($request->getParams()); - $xml = $params->getParam('xml', ['throw' => function () { - throw new InvalidArgumentException(__('Please supply the templates XML'), 'xml'); + $templateId = $params->getString('templateId', ['throw' => function () { + throw new InvalidArgumentException(__('Please supply a unique template ID'), 'templateId'); }]); + $dataType = $params->getString('dataType', ['throw' => function () { + throw new InvalidArgumentException(__('Please supply a data type'), 'dataType'); + }]); + + $template->isEnabled = $params->getCheckbox('enabled'); + + // TODO: validate? + $twig = $params->getParam('twig'); + $hbs = $params->getParam('hbs'); + $style = $params->getParam('style'); + $head = $params->getParam('head'); + $properties = $params->getParam('properties'); + $onTemplateRender = $params->getParam('onTemplateRender'); + $onTemplateVisible = $params->getParam('onTemplateVisible'); + + // We need to edit the XML we have for this template. + $document = $template->getDocument(); + + // Root nodes + $this->setNode($document, 'onTemplateRender', $onTemplateRender); + $this->setNode($document, 'onTemplateVisible', $onTemplateVisible); + + // Stencil nodes. + $stencilNodes = $document->getElementsByTagName('stencil'); + if ($stencilNodes->count() <= 0) { + $stencilNode = $document->createElement('stencil'); + $document->appendChild($stencilNode); + } else { + $stencilNode = $stencilNodes[0]; + } + + $this->setNode($document, 'twig', $twig, true, $stencilNode); + $this->setNode($document, 'hbs', $hbs, true, $stencilNode); + $this->setNode($document, 'style', $style, true, $stencilNode); + $this->setNode($document, 'head', $head, true, $stencilNode); + + // Properties. + // TODO: this is temporary pending a properties UI + // this is different because we want to replace the properties node with a new one. + if (!empty($properties)) { + $newProperties = new \DOMDocument(); + $newProperties->loadXML($properties); + + // Do we have new nodes to import? + if ($newProperties->childNodes->count() > 0) { + $importedPropteries = $document->importNode($newProperties->documentElement, true); + if ($importedPropteries !== false) { + $propertiesNodes = $document->getElementsByTagName('properties'); + if ($propertiesNodes->count() <= 0) { + $document->appendChild($importedPropteries); + } else { + $document->documentElement->replaceChild($importedPropteries, $propertiesNodes[0]); + } + } + } + } - // TODO: some checking that the templateId/dataType hasn't changed. - $template->setXml($xml); + // All done. + $template->setXml($document->saveXML()); $template->save(); if ($params->getCheckbox('isInvalidateWidget')) { $template->invalidate(); } + $this->getState()->hydrate([ + 'message' => sprintf(__('Edited %s'), $template->title), + 'id' => $template->id, + ]); + return $this->render($request, $response); } + + /** + * Helper function to set a node. + * @param \DOMDocument $document + * @param string $node + * @param string $value + * @param bool $cdata + * @param \DOMElement|null $childNode + * @return void + * @throws \DOMException + */ + private function setNode( + \DOMDocument $document, + string $node, + string $value, + bool $cdata = true, + ?\DOMElement $childNode = null + ): void { + $addTo = $childNode ?? $document->documentElement; + + $nodes = $addTo->getElementsByTagName($node); + if ($nodes->count() <= 0) { + if ($cdata) { + $element = $document->createElement($node); + $cdata = $document->createCDATASection($value); + $element->appendChild($cdata); + } else { + $element = $document->createElement($node, $value); + } + + $addTo->appendChild($element); + } else { + /** @var \DOMElement $element */ + $element = $nodes[0]; + if ($cdata) { + $cdata = $document->createCDATASection($value); + $element->textContent = $value; + + if ($element->firstChild !== null) { + $element->replaceChild($cdata, $element->firstChild); + } else { + //$element->textContent = ''; + $element->appendChild($cdata); + } + } else { + $element->textContent = $value; + } + } + } } diff --git a/lib/Entity/ModuleTemplate.php b/lib/Entity/ModuleTemplate.php index 321f5720e3..6c413b24c1 100644 --- a/lib/Entity/ModuleTemplate.php +++ b/lib/Entity/ModuleTemplate.php @@ -1,6 +1,6 @@ file === 'database') { - return $this->xml; - } else { - return file_get_contents($this->file); + if ($this->file !== 'database') { + $this->xml = file_get_contents($this->file); + } + return $this->xml; + } + + /** + * Set Document + * @param \DOMDocument $document + * @return void + */ + public function setDocument(\DOMDocument $document): void + { + $this->document = $document; + } + + /** + * Get this templates DOM document + * @return \DOMDocument + */ + public function getDocument(): \DOMDocument + { + if ($this->document === null) { + $this->document = new \DOMDocument(); + $this->document->load($this->getXml()); } + return $this->document; } /** @@ -280,12 +311,14 @@ private function edit(): void UPDATE `module_templates` SET `templateId` = :templateId, `dataType`= :dataType, + `enabled` = :enabled, `xml` = :xml WHERE `id` = :id ', [ 'templateId' => $this->templateId, 'dataType' => $this->dataType, 'xml' => $this->xml, + 'enabled' => $this->isEnabled ? 1 : 0, 'id' => $this->id, ]); } diff --git a/lib/Factory/ModuleTemplateFactory.php b/lib/Factory/ModuleTemplateFactory.php index 716ae427f5..8488855230 100644 --- a/lib/Factory/ModuleTemplateFactory.php +++ b/lib/Factory/ModuleTemplateFactory.php @@ -1,6 +1,6 @@ id = intval($row['id']); $template->templateId = $row['templateId']; $template->dataType = $row['dataType']; + $template->isEnabled = $row['enabled'] == 1; $templates[] = $template; } return $templates; @@ -275,6 +276,7 @@ public function createUserTemplate(string $xmlString): ModuleTemplate $template = $this->createFromXml($xml->documentElement, 'user', 'database'); $template->setXml($xmlString); + $template->setDocument($xml); return $template; } diff --git a/lib/Service/DisplayNotifyService.php b/lib/Service/DisplayNotifyService.php index 2db6b42545..2501fa33e6 100644 --- a/lib/Service/DisplayNotifyService.php +++ b/lib/Service/DisplayNotifyService.php @@ -1,6 +1,6 @@ log->debug('notifyByDataSetId: dataSetId: ' . $dataSetId); - if (in_array('dataSet', $this->keysProcessed)) { + if (in_array('dataSet_' . $dataSetId, $this->keysProcessed)) { $this->log->debug('notifyByDataSetId: already processed.'); return; } @@ -407,6 +407,33 @@ public function notifyByDataSetId($dataSetId) $this->store->update('UPDATE `task` SET `runNow` = 1 WHERE `class` LIKE :taskClassLike', [ 'taskClassLike' => '%WidgetSyncTask%', ]); + + // Query the schedule for any data connectors. + // This is a simple test to see if there are ever any schedules for this dataSetId + // TODO: this could be improved. + $sql = ' + SELECT DISTINCT display.displayId + FROM `schedule` + INNER JOIN `lkscheduledisplaygroup` + ON `lkscheduledisplaygroup`.eventId = `schedule`.eventId + INNER JOIN `lkdgdg` + ON `lkdgdg`.parentId = `lkscheduledisplaygroup`.displayGroupId + INNER JOIN `lkdisplaydg` + ON `lkdisplaydg`.DisplayGroupID = `lkdgdg`.childId + INNER JOIN `display` + ON `lkdisplaydg`.DisplayID = `display`.displayID + WHERE `schedule`.dataSetId = :dataSetId + '; + + foreach ($this->store->select($sql, ['dataSetId' => $dataSetId]) as $row) { + $this->displayIds[] = $row['displayId']; + + if ($this->collectRequired) { + $this->displayIdsRequiringActions[] = $row['displayId']; + } + } + + $this->keysProcessed[] = 'dataSet_' . $dataSetId; } /** @inheritdoc */ diff --git a/lib/routes-web.php b/lib/routes-web.php index 8d4398a7dc..737f505c15 100644 --- a/lib/routes-web.php +++ b/lib/routes-web.php @@ -1,6 +1,6 @@ get('/developer/template', ['\Xibo\Controller\Developer', 'templateGrid']) ->setName('developer.templates.search'); + $group->get('/developer/template/{id}', ['\Xibo\Controller\Developer', 'displayTemplateEditPage']) + ->setName('developer.templates.view.edit'); + $group->get('/developer/template/form/add', ['\Xibo\Controller\Developer', 'templateAddForm']) ->setName('developer.templates.form.add'); diff --git a/views/dataset-data-connector-page.twig b/views/dataset-data-connector-page.twig index 802ccc9dd2..48a9e9aa58 100644 --- a/views/dataset-data-connector-page.twig +++ b/views/dataset-data-connector-page.twig @@ -57,7 +57,9 @@
{{ "Data Connector JavaScript"|trans }} - +
diff --git a/views/developer-template-edit-page.twig b/views/developer-template-edit-page.twig new file mode 100644 index 0000000000..5be86e364c --- /dev/null +++ b/views/developer-template-edit-page.twig @@ -0,0 +1,204 @@ +{# +/* + * Copyright (C) 2024 Xibo Signage Ltd + * + * Xibo - Digital Signage - https://xibosignage.com + * + * This file is part of Xibo. + * + * Xibo is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * any later version. + * + * Xibo is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Xibo. If not, see . + */ +#} + +{% extends "authed.twig" %} +{% import "inline.twig" as inline %} +{% import "forms.twig" as forms %} + +{% block title %}{% set templateName = template.title %}{% trans %}{{ templateName }} - Module Template{% endtrans %} | {% endblock %} + +{% set hideNavigation = "1" %} + +{% block pageContent %} +
+ +
+
+
+
+
+ + +
+
+ {{ forms.alert("Changing the ID or DataType will break any existing Widgets which use this template.", "danger") }} + + {% set title %}{% trans "ID" %}{% endset %} + {% set helpText %}{% trans "A unique ID for the module template" %}{% endset %} + {{ forms.input("templateId", title, template.templateId, helpText) }} + + {% set title %}{% trans "Data Type" %}{% endset %} + {% set helpText %}{% trans "Which data type does this template need?" %}{% endset %} + {{ forms.input("dataType", title, template.dataType, helpText) }} + + {% set title %}{% trans "Enabled?" %}{% endset %} + {% set helpText %}{% trans "Is this template enabled?" %}{% endset %} + {{ forms.checkbox("enabled", title, template.isEnabled, helpText) }} +
+
+
+
+ {{ "Properties"|trans }} + + +
+
+
+
+
+
+
+
+
+ {{ "Twig"|trans }} + + +
+
+
+
+
+
+
+
+
+ {{ "HBS"|trans }} + + +
+
+
+
+
+
+
+
+
+ {{ "Style"|trans }} + + +
+
+
+
+
+
+
+
+
+ {{ "Head"|trans }} + + +
+
+
+
+
+
+
+
+
+ {{ "onTemplateRender"|trans }} + + +
+
+
+
+
+
+
+
+
+ {{ "onTemplateVisible"|trans }} + + +
+
+
+
+
+
+
+ + {{ forms.checkbox("isInvalidateWidget", "Invalidate any widgets using this template"|trans, 1) }} + + {{ forms.button("Save"|trans, "submit", null, null, null, "btn-success") }} +
+
+
+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/views/developer-template-form-add.twig b/views/developer-template-form-add.twig index a9667f7233..2fb45c3762 100644 --- a/views/developer-template-form-add.twig +++ b/views/developer-template-form-add.twig @@ -41,16 +41,13 @@ method="post" action="{{ url_for("developer.templates.add") }}"> -
-
- {{ "Module Template XML"|trans }} - + {% set title %}{% trans "ID" %}{% endset %} + {% set helpText %}{% trans "A unique ID for the module template" %}{% endset %} + {{ forms.input("templateId", title, "custom-", helpText) }} -
-
-
-
-
+ {% set title %}{% trans "Data Type" %}{% endset %} + {% set helpText %}{% trans "Which data type does this template need?" %}{% endset %} + {{ forms.input("dataType", title, "", helpText) }}
diff --git a/views/developer-template-form-edit.twig b/views/developer-template-form-edit.twig deleted file mode 100644 index 76a47fc4a8..0000000000 --- a/views/developer-template-form-edit.twig +++ /dev/null @@ -1,59 +0,0 @@ -{# -/* - * Copyright (C) 2023 Xibo Signage Ltd - * - * Xibo - Digital Signage - https://xibosignage.com - * - * This file is part of Xibo. - * - * Xibo is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * any later version. - * - * Xibo is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with Xibo. If not, see . - */ -#} - -{% extends "form-base.twig" %} -{% import "forms.twig" as forms %} - -{% block formTitle %} - {% trans "Edit Module Template" %} -{% endblock %} - -{% block formButtons %} - {% trans "Cancel" %}, XiboDialogClose() - {% trans "Save" %}, $("#form-module-template").submit(); -{% endblock %} - -{% block formHtml %} -
-
-
- -
-
- {{ "Module Template XML"|trans }} - - -
-
-
-
-
- - {{ forms.checkbox("isInvalidateWidget", "Invalidate any widgets using this template"|trans) }} -
-
-
-{% endblock %} From 5bc64bdebd6df3aa94347e3140f53a96cd677972 Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Mon, 26 Feb 2024 13:59:57 +0000 Subject: [PATCH 013/203] NPM: install after merge. --- package-lock.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4a3a2abc2e..b7a351525c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11135,9 +11135,9 @@ } }, "select2": { - "version": "4.0.13", - "resolved": "https://registry.npmjs.org/select2/-/select2-4.0.13.tgz", - "integrity": "sha512-1JeB87s6oN/TDxQQYCvS5EFoQyvV6eYMZZ0AeA4tdFDYWN3BAGZ8npr17UBFddU0lgAt3H0yjX3X6/ekOj1yjw==" + "version": "4.1.0-rc.0", + "resolved": "https://registry.npmjs.org/select2/-/select2-4.1.0-rc.0.tgz", + "integrity": "sha512-Hr9TdhyHCZUtwznEH2CBf7967mEM0idtJ5nMtjvk3Up5tPukOLXbHUNmh10oRfeNIhj+3GD3niu+g6sVK+gK0A==" }, "select2-bootstrap-theme": { "version": "0.1.0-beta.10", @@ -12455,8 +12455,8 @@ } }, "xibo-interactive-control": { - "version": "git+https://github.com/xibosignage/xibo-interactive-control.git#6bd108730ed670616c89356769f36a05fd605518", - "from": "git+https://github.com/xibosignage/xibo-interactive-control.git#6bd108730ed670616c89356769f36a05fd605518" + "version": "git+https://github.com/xibosignage/xibo-interactive-control.git#d911da8da056c12060e198c1b8da6fae8bf186fe", + "from": "git+https://github.com/xibosignage/xibo-interactive-control.git#d911da8da056c12060e198c1b8da6fae8bf186fe" }, "xtend": { "version": "4.0.2", From 7f40fb95bd2a5e709c0a82ed15a2d4b5f868dde3 Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Mon, 26 Feb 2024 14:35:21 +0000 Subject: [PATCH 014/203] Layout Editor: add playlist as a toolbar option rather than a widget (#2385) relates to xibosignageltd/xibo-private#631 Co-authored-by: maurofmferrao --- modules/subplaylist.xml | 2 +- ui/src/editor-core/toolbar.js | 398 +++++++++++++++--- ui/src/layout-editor/main.js | 81 +++- ui/src/playlist-editor/playlist.js | 35 +- ui/src/style/toolbar.scss | 90 +++- .../toolbar-card-playlist-new-template.hbs | 13 + .../toolbar-card-playlist-template.hbs | 29 ++ ui/src/templates/toolbar-content.hbs | 2 +- ui/src/templates/toolbar-search-form.hbs | 13 + views/common.twig | 5 + 10 files changed, 583 insertions(+), 85 deletions(-) create mode 100644 ui/src/templates/toolbar-card-playlist-new-template.hbs create mode 100644 ui/src/templates/toolbar-card-playlist-template.hbs diff --git a/modules/subplaylist.xml b/modules/subplaylist.xml index 7621d6ddc6..62e8c6322d 100644 --- a/modules/subplaylist.xml +++ b/modules/subplaylist.xml @@ -26,7 +26,7 @@ fa fa-list-ol \Xibo\Widget\Compatibility\SubPlaylistWidgetCompatibility - playlist + none subplaylist diff --git a/ui/src/editor-core/toolbar.js b/ui/src/editor-core/toolbar.js index 203fc5b273..246cfb5db2 100644 --- a/ui/src/editor-core/toolbar.js +++ b/ui/src/editor-core/toolbar.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023 Xibo Signage Ltd + * Copyright (C) 2024 Xibo Signage Ltd * * Xibo - Digital Signage - https://xibosignage.com * @@ -27,6 +27,10 @@ const ToolbarCardMediaUploadTemplate = require('../templates/toolbar-card-media-upload.hbs'); const ToolbarCardLayoutTemplateTemplate = require('../templates/toolbar-card-layout-template.hbs'); +const ToolbarCardPlaylistTemplate = + require('../templates/toolbar-card-playlist-template.hbs'); +const ToolbarCardNewPlaylistTemplate = + require('../templates/toolbar-card-playlist-new-template.hbs'); const ToolbarContentTemplate = require('../templates/toolbar-content.hbs'); const ToolbarSearchFormTemplate = require('../templates/toolbar-search-form.hbs'); @@ -156,19 +160,6 @@ Toolbar.prototype.init = function({isPlaylist = false} = {}) { } }); - // Add playlist to modules - if (!isPlaylist) { - moduleListFiltered.push({ - moduleId: 'playlist', - name: toolbarTrans.playlist, - type: 'playlist', - icon: 'fa fa-list-ol', - dataType: '', - regionSpecific: 1, - group: [], - }); - } - // Add zone to template edit mode if ( !isPlaylist && @@ -271,6 +262,9 @@ Toolbar.prototype.init = function({isPlaylist = false} = {}) { name: { value: '', }, + user: { + value: '', + }, tag: { value: '', }, @@ -307,6 +301,32 @@ Toolbar.prototype.init = function({isPlaylist = false} = {}) { itemCount: 0, favouriteModules: [], }, + { + name: 'playlists', + itemName: toolbarTrans.menuItems.playlistsName, + itemIcon: 'list', + itemTitle: toolbarTrans.menuItems.playlistsTitle, + contentType: 'playlists', + filters: { + name: { + value: '', + key: 'name', + }, + tag: { + value: '', + key: 'tags', + dataRole: 'tagsinput', + }, + user: { + value: '', + key: 'users', + dataRole: 'usersList', + searchUrl: urlsForApi.user.get.url, + }, + }, + state: '', + itemCount: 0, + }, { name: 'global', disabled: isPlaylist ? true : false, @@ -1043,6 +1063,7 @@ Toolbar.prototype.createContent = function( if ( !forceReload && this.menuItems[menu].contentType != 'modules' && + this.menuItems[menu].contentType != 'playlists' && this.menuItems[menu].contentType != 'actions' && self.DOMObject .find( @@ -1072,6 +1093,8 @@ Toolbar.prototype.createContent = function( this.elementsContentCreateWindow(menu, savePrefs); } else if (content.contentType === 'layout_templates') { this.layoutTemplatesContentCreateWindow(menu); + } else if (content.contentType === 'playlists') { + this.playlistsContentCreateWindow(menu); } else { this.handleCardsBehaviour(); @@ -1405,7 +1428,6 @@ Toolbar.prototype.mediaContentCreateWindow = function(menu) { Toolbar.prototype.mediaContentPopulate = function(menu) { const self = this; const app = this.parent; - const filters = self.menuItems[menu].filters; const $mediaContainer = self.DOMObject.find('#media-container-' + menu); // Request elements based on filters @@ -1476,41 +1498,6 @@ Toolbar.prototype.mediaContentPopulate = function(menu) { provider: 'both', }, filter), }).done(function(res) { - // Add upload card - const showUploadCard = function() { - // Add card to content - const addUploadCard = function(module) { - if (module) { - module.trans = toolbarTrans; - - module.trans.upload = - module.trans.uploadType.replace('%obj%', module.name); - - const $uploadCard = $(ToolbarCardMediaUploadTemplate( - Object.assign( - {}, - module, - { - editingPlaylist: self.isPlaylist, - })), - ); - $mediaContent.append($uploadCard).masonry('appended', $uploadCard); - } - }; - - if ($mediaContent.find('.upload-card').length == 0) { - // If we have a specific module type - if (filter.type != '') { - addUploadCard(app.common.getModuleByType(filter.type)); - } else { - // Show one upload card for each media type - self.moduleListOtherTypes.forEach((moduleType) => { - addUploadCard(app.common.getModuleByType(moduleType)); - }); - } - } - }; - // Remove loading $mediaContent.parent().find('.loading-container-toolbar').remove(); @@ -1529,12 +1516,42 @@ Toolbar.prototype.mediaContentPopulate = function(menu) { gutter: 8, }).addClass('masonry-container'); + // Show upload card + const addUploadCard = function(module) { + if (module) { + module.trans = toolbarTrans; + + module.trans.upload = + module.trans.uploadType.replace('%obj%', module.name); + + const $uploadCard = $(ToolbarCardMediaUploadTemplate( + Object.assign( + {}, + module, + { + editingPlaylist: self.isPlaylist, + })), + ); + $mediaContent.append($uploadCard).masonry('appended', $uploadCard); + } + }; + + if ($mediaContent.find('.upload-card').length == 0) { + // If we have a specific module type + if (filter.type != '') { + addUploadCard(app.common.getModuleByType(filter.type)); + } else { + // Show one upload card for each media type + self.moduleListOtherTypes.forEach((moduleType) => { + addUploadCard(app.common.getModuleByType(moduleType)); + }); + } + } + if ( (!res.data || res.data.length == 0) && $mediaContent.find('.toolbar-card').length == 0 ) { - showUploadCard(); - // Handle card behaviour self.handleCardsBehaviour(); @@ -1544,8 +1561,6 @@ Toolbar.prototype.mediaContentPopulate = function(menu) { toolbarTrans.noMediaToShow + '
'); } else { - showUploadCard(); - for (let index = 0; index < res.data.length; index++) { const element = Object.assign({}, res.data[index]); element.trans = toolbarTrans; @@ -1638,10 +1653,14 @@ Toolbar.prototype.mediaContentPopulate = function(menu) { } } }); + + // Initialise tooltips + app.common.reloadTooltips($mediaContent); }; // Refresh the table results - const filterRefresh = function(filters) { + const filterRefresh = function() { + const filters = self.menuItems[menu].filters; // Save filter options for (const filter in filters) { if (filters.hasOwnProperty(filter)) { @@ -1756,20 +1775,19 @@ Toolbar.prototype.mediaContentPopulate = function(menu) { '.media-search-form select, ' + '.media-search-form input[type="text"].input-tag', ).change(_.debounce(function() { - filterRefresh(filters); + filterRefresh(); }, 200)); // Bind tags change to refresh the results $mediaContainer.find('.media-search-form input[type="text"]') .on('input', _.debounce(function() { - filterRefresh(filters); + filterRefresh(); }, 500)); // Initialize tagsinput const $tags = $mediaContainer .find('.media-search-form input[data-role="tagsinput"]'); $tags.tagsinput(); - $mediaContainer.find('#media-' + menu).off('click') .on('click', '#tagDiv .btn-tag', function(e) { // Add text to form @@ -1857,13 +1875,38 @@ Toolbar.prototype.layoutTemplatesContentCreateWindow = function(menu) { this.layoutTemplatesContentPopulate(menu); }; +/** + * Create playlists window + * @param {number} menu - menu index + */ +Toolbar.prototype.playlistsContentCreateWindow = function(menu) { + const self = this; + + // Deselect previous selections + self.deselectCardsAndDropZones(); + + // Render template + const html = ToolbarContentMediaTemplate({ + menuIndex: menu, + filters: this.menuItems[menu].filters, + trans: toolbarTrans, + formClass: 'playlists-search-form media-search-form', + }); + + // Append template to the search main div + self.DOMObject.find('#playlists-container-' + menu).html(html); + + // Load content + this.playlistsContentPopulate(menu); +}; + /** * Create content for layout templates * @param {number} menu - menu index */ Toolbar.prototype.layoutTemplatesContentPopulate = function(menu) { + const app = this.parent; const self = this; - const filters = self.menuItems[menu].filters; const $container = self.DOMObject.find('#layout_templates-container-' + menu); const $content = self.DOMObject.find('#media-content-' + menu); const $searchForm = $container.parent().find('.toolbar-search-form'); @@ -2003,6 +2046,9 @@ Toolbar.prototype.layoutTemplatesContentPopulate = function(menu) { toolbarTrans.noMediaToShow + ''); } + + // Initialise tooltips + app.common.reloadTooltips($container); }, error: function(jqXHR, textStatus, errorThrown) { $container.parent().find('.loading-container-toolbar').remove(); @@ -2012,7 +2058,8 @@ Toolbar.prototype.layoutTemplatesContentPopulate = function(menu) { }; // Refresh the results - const filterRefresh = function(filters) { + const filterRefresh = function() { + const filters = self.menuItems[menu].filters; // Save filter options for (const filter in filters) { if (filters.hasOwnProperty(filter)) { @@ -2020,7 +2067,7 @@ Toolbar.prototype.layoutTemplatesContentPopulate = function(menu) { self.DOMObject.find( '#content-' + menu + - ' .template-search-form #input-' + filter, + ' .layout_tempates-search-form #input-' + filter, ).val(); } } @@ -2039,9 +2086,236 @@ Toolbar.prototype.layoutTemplatesContentPopulate = function(menu) { // Bind search action to refresh the results $searchForm.find('select, input[type="text"]').change(_.debounce(function() { - filterRefresh(filters); + filterRefresh(); }, 200)); + // Initialize other select inputs + $container + .find('.media-search-form select:not([name="ownerId"]):not(.input-sort)') + .select2({ + minimumResultsForSearch: -1, // Hide search box + }); + + loadData(); +}; + +/** + * Create content for playlists + * @param {number} menu - menu index + */ +Toolbar.prototype.playlistsContentPopulate = function(menu) { + const self = this; + const app = this.parent; + const $container = self.DOMObject.find('#playlists-container-' + menu); + const $content = self.DOMObject.find('#media-content-' + menu); + const $searchForm = $container.parent().find('.toolbar-search-form'); + + // Load playlists + const loadData = function(clear = true) { + // Remove show more button + $container.find('.show-more').remove(); + + // Empty content and reset item count + if (clear) { + // Clear selection + self.deselectCardsAndDropZones(); + + // Empty content + $content.empty(); + self.menuItems[menu].itemCount = 0; + } + + // Show loading + $content.before( + `
+ +
`); + + // Remove no media message if exists + $searchForm.find('.no-results-message').remove(); + + // Manage request length + const requestLength = 15; + + // Filter start + const start = self.menuItems[menu].itemCount; + + $.ajax({ + method: 'GET', + url: urlsForApi.playlist.get.url, + data: $.extend({ + start: start, + length: requestLength, + provider: 'both', + }, $searchForm.serializeObject()), + success: function(response) { + $container.parent().find('.loading-container-toolbar').remove(); + + // Send translation with module object + module.trans = toolbarTrans; + + // Show new playlist card + if ($container.find('.new-playlist-card').length == 0) { + const $playlistCard = $(ToolbarCardNewPlaylistTemplate( + Object.assign( + {}, + module, + { + editingPlaylist: self.isPlaylist, + })), + ); + $content.append($playlistCard); + } + + if (response && response.data && response.data.length > 0) { + // Process each row returned + $.each(response.data, function(index, el) { + // Enhance with some properties + el.trans = toolbarTrans; + el.showFooter = el.orientation || + (el.provider && el.provider.logoUrl) || + (el.tags && el.tags.length > 0); + el.thumbnail = el.thumbnail || defaultThumbnailUrl; + + // Provider logos do not have the root uri + if (el.provider && el.provider.logoUrl) { + el.provider.logoUrl = $('meta[name=public-path]') + .attr('content') + el.provider.logoUrl; + } + + if (el.provider && !el.provider.link) { + el.provider.link = '#'; + } + + // Editing playlist? + el.editingPlaylist = self.isPlaylist; + + // Format duration + if (typeof el.duration === 'number') { + el.playlistDuration = + app.common.timeFormat(el.duration); + } + + // Get template and add + const $card = $(ToolbarCardPlaylistTemplate(el)); + + // Add data object to card + if ($card.hasClass('from-provider')) { + $card.data('providerData', response.data[index]); + } + + // Append to container + $content.append($card); + + self.menuItems[menu].itemCount++; + }); + + // Show content in widgets + $content.find('.toolbar-card').removeClass('hide-content'); + + // Show more button + if (response.data.length > 0) { + if ($content.parent().find('.show-more').length == 0) { + const $showMoreBtn = + $(''); + $content.after($showMoreBtn); + + $showMoreBtn.off('click').on('click', function() { + loadData(false); + }); + } + } else { + toastr.info( + toolbarTrans.noShowMore, + null, + {positionClass: 'toast-bottom-center'}, + ); + } + + // Fix for scrollbar + const $parent = $content.parent(); + $parent.toggleClass( + 'scroll', + ($parent.width() < $parent[0].scrollHeight), + ); + + self.handleCardsBehaviour(); + } else if (response.login) { + window.location.href = window.location.href; + location.reload(); + } else if ($content.find('.toolbar-card').length === 0) { + $searchForm.append( + '
' + + toolbarTrans.noMediaToShow + + '
'); + } + + // Initialise tooltips + app.common.reloadTooltips($container); + }, + error: function(jqXHR, textStatus, errorThrown) { + $container.parent().find('.loading-container-toolbar').remove(); + console.error(jqXHR); + }, + }); + }; + + // Refresh the results + const filterRefresh = function() { + const filters = self.menuItems[menu].filters; + // Save filter options + for (const filter in filters) { + if (filters.hasOwnProperty(filter)) { + filters[filter].value = + self.DOMObject.find( + '#content-' + + menu + + ' .playlists-search-form #input-' + filter, + ).val(); + } + } + + // Reload data + loadData(); + + self.savePrefs(); + }; + + // Prevent filter form submit and bind the change event to reload the table + $searchForm.on('submit', function(e) { + e.preventDefault(); + return false; + }); + + // Bind search action to refresh the results + $searchForm.find('select, input[type="text"]').change(_.debounce(function() { + filterRefresh(); + }, 200)); + + // Initialize tagsinput + const $tags = $container + .find('.media-search-form input[data-role="tagsinput"]'); + $tags.tagsinput(); + $container.find('#media-' + menu).off('click') + .on('click', '#tagDiv .btn-tag', function(e) { + // Add text to form + $tags.tagsinput('add', $(e.target).text(), {allowDuplicates: false}); + }); + + // Initialize user list input + const $userListInput = $container + .find('.media-search-form select[name="userId"]'); + makePagedSelect($userListInput); + + // Initialize other select inputs + $container + .find('.media-search-form select:not([name="userId"]):not(.input-sort)') + .select2({ + minimumResultsForSearch: -1, // Hide search box + }); + loadData(); }; diff --git a/ui/src/layout-editor/main.js b/ui/src/layout-editor/main.js index d5d5c8a44f..84a7a97a80 100644 --- a/ui/src/layout-editor/main.js +++ b/ui/src/layout-editor/main.js @@ -2218,21 +2218,75 @@ lD.dropItemAdd = function(droppable, draggable, dropPosition) { false, ); } else { - // Save zone or playlist to a temp object - // so it can be selected after refreshing - lD.viewer.saveTemporaryObject( - 'region_' + res.data.regionId, - 'region', - { - type: 'region', - }, - ); + const reloadRegion = function() { + // Save zone or playlist to a temp object + // so it can be selected after refreshing + lD.viewer.saveTemporaryObject( + 'region_' + res.data.regionId, + 'region', + { + type: 'region', + }, + ); - // Reload data ( and viewer ) - lD.reloadData(lD.layout, - { - refreshEditor: true, + // Reload data ( and viewer ) + lD.reloadData(lD.layout, + { + refreshEditor: true, + }); + }; + + // If we're adding a specific playlist, we need to create a + // subplaylist inside the new playlist + if (draggableData.subPlaylistId) { + lD.addModuleToPlaylist( + res.data.regionId, + res.data.regionPlaylist.playlistId, + 'subplaylist', + draggableData, + null, + false, + false, + true, + false, + false, + ).then((res) => { + // Update playlist values in the new widget + lD.historyManager.addChange( + 'saveForm', + 'widget', // targetType + res.data.widgetId, // targetId + null, // oldValues + { + subPlaylists: JSON.stringify([ + { + rowNo: 1, + playlistId: draggableData.subPlaylistId, + spots: '', + spotLength: '', + spotFill: 'repeat', + }, + ]), + }, // newValues + { + addToHistory: false, + }, + ).then((_res) => { + console.log(_res); + reloadRegion(); + }).catch((_error) => { + toastr.error(_error); + + // Delete new region + lD.layout.deleteObject( + 'region', + res.data.regionPlaylist.regionId, + ); + }); }); + } else { + reloadRegion(); + } } }); } @@ -2385,7 +2439,6 @@ lD.addModuleToPlaylist = function( } // Save the new widget as temporary - if (selectNewWidget) { lD.viewer.saveTemporaryObject( 'widget_' + regionId + '_' + res.data.widgetId, diff --git a/ui/src/playlist-editor/playlist.js b/ui/src/playlist-editor/playlist.js index 9cae4198e9..f54720235b 100644 --- a/ui/src/playlist-editor/playlist.js +++ b/ui/src/playlist-editor/playlist.js @@ -261,7 +261,40 @@ Playlist.prototype.addObject = function( pE.selectedObject.id = 'widget_' + res.data.widgetId; pE.selectedObject.type = 'widget'; - pE.reloadData(); + // If we're adding a specific playlist, we need to + // update playlist values in the new widget + const subPlaylistId = $(draggable).data('subPlaylistId'); + if (subPlaylistId) { + pE.historyManager.addChange( + 'saveForm', + 'widget', // targetType + res.data.widgetId, // targetId + null, // oldValues + { + subPlaylists: JSON.stringify([ + { + rowNo: 1, + playlistId: subPlaylistId, + spots: '', + spotLength: '', + spotFill: 'repeat', + }, + ]), + }, // newValues + { + addToHistory: false, + }, + ).then((_res) => { + pE.reloadData(); + }).catch((_error) => { + toastr.error(_error); + + // Delete newly added widget + pE.deleteObject('widget', res.data.widgetId); + }); + } else { + pE.reloadData(); + } }).catch((error) => { // Fail/error pE.common.hideLoadingScreen('addModuleToPlaylist'); diff --git a/ui/src/style/toolbar.scss b/ui/src/style/toolbar.scss index 63558cc579..94b7fa3d92 100644 --- a/ui/src/style/toolbar.scss +++ b/ui/src/style/toolbar.scss @@ -427,6 +427,7 @@ animation: animateBg 1.5s linear infinite; background-image: linear-gradient(0deg, $xibo-color-primary,lighten($xibo-color-primary, 10%),$xibo-color-primary); background-size: 100% 1100%; + border-radius: 4px; & > * { opacity: 0; @@ -437,7 +438,7 @@ 100% { background-position: 0% 100%; } } - &.no-thumbnail, &.upload-card { + &.no-thumbnail, &.upload-card, &.new-playlist-card { min-height: 85px; .toolbar-card-preview { @@ -448,8 +449,10 @@ } } - &.upload-card .toolbar-card-preview { - background: $xibo-color-primary-d60; + &.upload-card, &.new-playlist-card { + .toolbar-card-preview { + background: $xibo-color-primary-d60; + } } .toolbar-card-preview { @@ -555,16 +558,22 @@ .toolbar-card:not(.has-thumb):hover .media-title { overflow: visible; white-space: normal; + @include set-transparent-color(background-color, $xibo-color-primary-l5, 0.75); + position: relative; + z-index: calc(#{$toolbar-card-z-index} + 1); } - - .toolbar-card:not(.has-thumb):hover .badges { + + .badges { display: flex; + padding-bottom: 4px; + } + + .toolbar-card:not(.has-thumb):hover .badges { position: absolute; width: 100%; flex-wrap: wrap; z-index: calc(#{$toolbar-card-z-index} + 1); gap: 3px; - padding: 4px 0; @include set-transparent-color(background-color, $xibo-color-primary-l5, 0.75); border-radius: 0 0 4px 4px; } @@ -574,6 +583,75 @@ overflow: visible; } } + + &.toolbar-playlists-pane { + .new-playlist-card { + min-height: initial; + + .card-icon { + margin: 5px 0; + } + + .media-title { + color: $xibo-color-primary-l10; + text-align: center; + font-weight: normal; + } + } + + .toolbar-card { + margin-bottom: 2px; + padding-bottom: 6px; + border-radius: 4px; + } + + .toolbar-card-preview { + .media-title { + font-weight: normal; + } + + .dynamic-logo { + position: absolute; + font-size: 0.85rem; + bottom: 0; + right: 0; + background: $xibo-color-primary; + color: $xibo-color-primary-l10; + padding: 0px 4px; + @include border-radius(4px 0 0 0); + opacity: 0.9; + line-height: 20px; + } + + min-height: $toolbar-widgets-card-height; + height: $toolbar-widgets-card-height; + background: $xibo-color-primary-l20; + color: $xibo-color-primary-d60; + border: 1px solid $xibo-color-primary-l30; + padding: 4px; + margin-bottom: 2px; + } + + .badges { + padding-bottom: 4px; + display: flex; + position: absolute; + width: 100%; + gap: 3px; + @include set-transparent-color(background-color, $xibo-color-primary-l5, 0.75); + border-radius: 0 0 4px 4px; + } + + .toolbar-card:not(.has-thumb):hover .badges { + flex-wrap: wrap; + z-index: calc(#{$toolbar-card-z-index} + 1); + } + + .toolbar-card:not(.has-thumb):hover { + @include set-transparent-color(background-color, $xibo-color-primary-l5, 0.75); + overflow: visible; + } + } } &.toolbar-widgets-pane, &.toolbar-actions-pane, &.toolbar-global-pane { diff --git a/ui/src/templates/toolbar-card-playlist-new-template.hbs b/ui/src/templates/toolbar-card-playlist-new-template.hbs new file mode 100644 index 0000000000..3f9d8ff41b --- /dev/null +++ b/ui/src/templates/toolbar-card-playlist-new-template.hbs @@ -0,0 +1,13 @@ +
+ +
+ + {{trans.newPlaylist}} +
+
\ No newline at end of file diff --git a/ui/src/templates/toolbar-card-playlist-template.hbs b/ui/src/templates/toolbar-card-playlist-template.hbs new file mode 100644 index 0000000000..911829b67c --- /dev/null +++ b/ui/src/templates/toolbar-card-playlist-template.hbs @@ -0,0 +1,29 @@ +
+ +
+ {{name}} + + {{#if playlistDuration}} +
+ {{playlistDuration}} +
+ {{/if}} + + {{#if isDynamic}} + + {{/if}} +
+ +
+ {{#each tags}} + {{this.tag}} + {{/each}} +
+
diff --git a/ui/src/templates/toolbar-content.hbs b/ui/src/templates/toolbar-content.hbs index fafbd4739f..eb5df5f3fc 100644 --- a/ui/src/templates/toolbar-content.hbs +++ b/ui/src/templates/toolbar-content.hbs @@ -1,5 +1,5 @@
diff --git a/ui/src/templates/toolbar-search-form.hbs b/ui/src/templates/toolbar-search-form.hbs index 676237952a..89549267dc 100644 --- a/ui/src/templates/toolbar-search-form.hbs +++ b/ui/src/templates/toolbar-search-form.hbs @@ -28,6 +28,19 @@ data-text-property="userName">
+ {{else eq @key "user"}} +
+ + +
{{else eq @key "orientation"}}
diff --git a/views/common.twig b/views/common.twig index 13a1dfb78e..183ca42a4f 100644 --- a/views/common.twig +++ b/views/common.twig @@ -653,17 +653,20 @@ select: "{{ "Select" |trans }}", deselect: "{{ "Deselect" |trans }}", duration: "{{ "Duration" |trans }}", + dynamicPlaylist: "{{ "Dynamic Playlist" |trans }}", preview: "{{ "Preview media" |trans }}", open: "{{ "Open" |trans }}", addAsFavourite: "{{ "Mark as favourite" |trans }}", upload: "{{ "Upload new" |trans }}", uploadType: "{{ "Upload %obj%" |trans }}", + newPlaylist: "{{ "New Playlist" |trans }}", searchFilters: { search: "{{ "Search" |trans }}", name: "{{ "Name" |trans }}", tag: "{{ "Tag" |trans }}", type: "{{ "Type" |trans }}", owner: "{{ "Owner"|trans }}", + user: "{{ "Owner"|trans }}", orientation: "{{ "Orientation"|trans }}", provider: "{{ "Provider"|trans }}", }, @@ -706,6 +709,8 @@ actionsTitle: "{{ "Interactive actions"|trans }}", layoutTemplateName: "{{ "Layout Templates" |trans }}", layoutTemplateTitle: "{{ "Search for Layout Templates"|trans }}", + playlistsName: "{{ "Playlists" |trans }}", + playlistsTitle: "{{ "Add Playlists"|trans }}", }, window: { drag: "{{ "Move Window" |trans }}", From aa3487992bb50a95f505df6ede2a240f76ec1a15 Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Tue, 27 Feb 2024 11:23:56 +0000 Subject: [PATCH 015/203] Field lengths and missing indexes (#2388) * Menu board: field length for description and allergy info. Form/Grid updates to accommodate. fixes xibosignage/xibo#3203 * DB: missing indexes fixes xibosignageltd/xibo-private#575 --- ...0600_menuboard_field_lengths_migration.php | 46 ++++++++++++++ ...240227102400_missing_indexes_migration.php | 47 ++++++++++++++ views/menuboard-product-form-add.twig | 28 +++++---- views/menuboard-product-form-edit.twig | 28 +++++---- views/menuboard-product-page.twig | 61 ++++++++++--------- 5 files changed, 156 insertions(+), 54 deletions(-) create mode 100644 db/migrations/20240227100600_menuboard_field_lengths_migration.php create mode 100644 db/migrations/20240227102400_missing_indexes_migration.php diff --git a/db/migrations/20240227100600_menuboard_field_lengths_migration.php b/db/migrations/20240227100600_menuboard_field_lengths_migration.php new file mode 100644 index 0000000000..1ff74f4190 --- /dev/null +++ b/db/migrations/20240227100600_menuboard_field_lengths_migration.php @@ -0,0 +1,46 @@ +. + */ + +use Phinx\Migration\AbstractMigration; + +/** + * Migration for adjusting the field length of the menu board product description/allergyInfo + * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace + */ +class MenuboardFieldLengthsMigration extends AbstractMigration +{ + public function change(): void + { + $this->table('menu_product') + ->changeColumn('description', 'text', [ + 'limit' => \Phinx\Db\Adapter\MysqlAdapter::TEXT_REGULAR, + 'default' => null, + 'null' => true, + ]) + ->changeColumn('allergyInfo', 'text', [ + 'limit' => \Phinx\Db\Adapter\MysqlAdapter::TEXT_REGULAR, + 'default' => null, + 'null' => true, + ]) + ->save(); + } +} diff --git a/db/migrations/20240227102400_missing_indexes_migration.php b/db/migrations/20240227102400_missing_indexes_migration.php new file mode 100644 index 0000000000..f8c71eebb2 --- /dev/null +++ b/db/migrations/20240227102400_missing_indexes_migration.php @@ -0,0 +1,47 @@ +. + */ + +use Phinx\Migration\AbstractMigration; + +/** + * Migration for adding missing indexes + * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace + */ +class MissingIndexesMigration extends AbstractMigration +{ + public function change(): void + { + $region = $this->table('region'); + if (!$region->hasForeignKey('layoutId')) { + $region + ->addForeignKey('layoutId', 'latout') + ->save(); + } + + $playlist = $this->table('playlist'); + if (!$playlist->hasIndex('regionId')) { + $playlist + ->addIndex('regionId') + ->save(); + } + } +} diff --git a/views/menuboard-product-form-add.twig b/views/menuboard-product-form-add.twig index 1e517ed884..7a71c48a11 100644 --- a/views/menuboard-product-form-add.twig +++ b/views/menuboard-product-form-add.twig @@ -41,6 +41,7 @@
@@ -51,22 +52,10 @@ {% set helpText %}{% trans "The Name for this Menu Board Product" %}{% endset %} {{ forms.input("name", title, "", helpText) }} - {% set title %}{% trans "Description" %}{% endset %} - {% set helpText %}{% trans "The Description for this Menu Board Product" %}{% endset %} - {{ forms.input("description", title, "", helpText) }} - {% set title %}{% trans "Price" %}{% endset %} {% set helpText %}{% trans "The Price for this Menu Board Product" %}{% endset %} {{ forms.number("price", title, "", helpText) }} - {% set title %}{% trans "Allergy Information" %}{% endset %} - {% set helpText %}{% trans "The Allergy Information for this Menu Board Product" %}{% endset %} - {{ forms.input("allergyInfo", title, "", helpText) }} - - {% set title %}{% trans "Calories" %}{% endset %} - {% set helpText %}{% trans "How many calories are in this product?" %}{% endset %} - {{ forms.number("calories", title, "", helpText) }} - {% set title %}{% trans "Display Order" %}{% endset %} {% set helpText %}{% trans "Set a display order for this item to appear, leave empty to add to the end." %}{% endset %} {{ forms.number('displayOrder', title, null, helpText) }} @@ -94,6 +83,21 @@ ] %} {{ forms.dropdown("mediaId", "single", title, "", null, "mediaId", "media", helpText, "pagedSelect", "", "d", "", attributes) }}
+ +
+ {% set title %}{% trans "Description" %}{% endset %} + {% set helpText %}{% trans "The Description for this Menu Board Product" %}{% endset %} + {{ forms.textarea("description", title, "", helpText) }} + + {% set title %}{% trans "Allergy Information" %}{% endset %} + {% set helpText %}{% trans "The Allergy Information for this Menu Board Product" %}{% endset %} + {{ forms.textarea("allergyInfo", title, "", helpText) }} + + {% set title %}{% trans "Calories" %}{% endset %} + {% set helpText %}{% trans "How many calories are in this product?" %}{% endset %} + {{ forms.number("calories", title, "", helpText) }} +
+
{% set message %} {% trans "If required please provide additional options and their prices for this Product." %}{% endset %} {{ forms.message(message, 'alert alert-info') }} diff --git a/views/menuboard-product-form-edit.twig b/views/menuboard-product-form-edit.twig index ca40e5567c..7f063bc7f5 100644 --- a/views/menuboard-product-form-edit.twig +++ b/views/menuboard-product-form-edit.twig @@ -41,6 +41,7 @@
@@ -51,22 +52,10 @@ {% set helpText %}{% trans "The Name for this Menu Board Product" %}{% endset %} {{ forms.input("name", title, menuBoardProduct.name, helpText) }} - {% set title %}{% trans "Description" %}{% endset %} - {% set helpText %}{% trans "The Description for this Menu Board Product" %}{% endset %} - {{ forms.input("description", title, menuBoardProduct.description, helpText) }} - {% set title %}{% trans "Price" %}{% endset %} {% set helpText %}{% trans "The Price for this Menu Board Product" %}{% endset %} {{ forms.number("price", title, menuBoardProduct.price, helpText) }} - {% set title %}{% trans "Allergy Information" %}{% endset %} - {% set helpText %}{% trans "The Allergy Information for this Menu Board Product" %}{% endset %} - {{ forms.input("allergyInfo", title, menuBoardProduct.allergyInfo, helpText) }} - - {% set title %}{% trans "Calories" %}{% endset %} - {% set helpText %}{% trans "How many calories are in this product?" %}{% endset %} - {{ forms.number("calories", title, menuBoardProduct.calories, helpText) }} - {% set title %}{% trans "Display Order" %}{% endset %} {% set helpText %}{% trans "Set a display order for this item to appear" %}{% endset %} {{ forms.number('displayOrder', title, menuBoardProduct.displayOrder, helpText) }} @@ -94,6 +83,21 @@ ] %} {{ forms.dropdown("mediaId", "single", title, menuBoardProduct.mediaId, [media], "mediaId", "name", helpText, "pagedSelect", "", "d", "", attributes) }}
+ +
+ {% set title %}{% trans "Description" %}{% endset %} + {% set helpText %}{% trans "The Description for this Menu Board Product" %}{% endset %} + {{ forms.textarea("description", title, menuBoardProduct.description, helpText) }} + + {% set title %}{% trans "Allergy Information" %}{% endset %} + {% set helpText %}{% trans "The Allergy Information for this Menu Board Product" %}{% endset %} + {{ forms.textarea("allergyInfo", title, menuBoardProduct.allergyInfo, helpText) }} + + {% set title %}{% trans "Calories" %}{% endset %} + {% set helpText %}{% trans "How many calories are in this product?" %}{% endset %} + {{ forms.number("calories", title, menuBoardProduct.calories, helpText) }} +
+
{% set message %} {% trans "If required please provide additional options and their prices for this Product." %}{% endset %} {{ forms.message(message, 'alert alert-info') }} diff --git a/views/menuboard-product-page.twig b/views/menuboard-product-page.twig index be8c5ca6c1..17b0d6f488 100644 --- a/views/menuboard-product-page.twig +++ b/views/menuboard-product-page.twig @@ -92,23 +92,23 @@ filter: false, searchDelay: 3000, dataType: 'json', - "order": [[5, "asc"]], + order: [[5, 'asc']], ajax: { url: "{{ url_for("menuBoard.product.search", {id: menuBoardCategory.menuCategoryId}) }}", - "data": function (d) { + data: function (d) { $.extend(d, $("#menuBoardProducts").closest(".XiboGrid").find(".FilterDiv form").serializeObject()); } }, - "columns": [ - {"data": "menuProductId", responsivePriority: 2}, + columns: [ + {data: 'menuProductId', responsivePriority: 2}, { - "data": "name", + data: 'name', responsivePriority: 2, - "render": dataTableSpacingPreformatted + render: dataTableSpacingPreformatted, }, { - "data": "description", - responsivePriority: 2 + data: 'description', + responsivePriority: 3, }, { data: 'price', @@ -131,49 +131,50 @@ if (row.thumbnail && row.thumbnail !== '') { return '' + - 'Thumbnail'; + 'Thumbnail'; } else { return ''; } - } + }, }, { data: 'displayOrder', responsivePriority: 2, }, { - "data": "availability", - "render": function (data, type, row) { - if (type != "display") - return data; - - var icon = ""; - if (data == 1) { - icon = "fa-check"; - } - else if (data == 0) { - icon = "fa-times"; - } + data: 'availability', + responsivePriority: 2, + render: function (data, type, row) { + if (type !== 'display') { + return data; + } - return ''; + let icon = ''; + if (data === 1) { + icon = 'fa-check'; + } else if (data === 0) { + icon = 'fa-times'; } + + return ''; + }, }, { - "data": "allergyInfo", - responsivePriority: 2 + data: 'allergyInfo', + responsivePriority: 3, }, { data: 'calories', - responsivePriority: 2 + responsivePriority: 2, }, { - "data": "code", - responsivePriority: 3 + data: 'code', + responsivePriority: 2, }, { - "orderable": false, + orderable: false, responsivePriority: 1, - "data": dataTableButtonsColumn + data: dataTableButtonsColumn, } ] }); From 9d630846b407d2e82843a0e721c82b74147dff7a Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Tue, 27 Feb 2024 14:43:26 +0000 Subject: [PATCH 016/203] DB: fix missing indexes (#2391) fixes xibosignageltd/xibo-private#575 --- db/migrations/20240227102400_missing_indexes_migration.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/migrations/20240227102400_missing_indexes_migration.php b/db/migrations/20240227102400_missing_indexes_migration.php index f8c71eebb2..a62fdefdee 100644 --- a/db/migrations/20240227102400_missing_indexes_migration.php +++ b/db/migrations/20240227102400_missing_indexes_migration.php @@ -33,7 +33,7 @@ public function change(): void $region = $this->table('region'); if (!$region->hasForeignKey('layoutId')) { $region - ->addForeignKey('layoutId', 'latout') + ->addForeignKey('layoutId', 'layout') ->save(); } From 7c639583783b6123ff74075d4ee3ba9bbaf7f440 Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Tue, 27 Feb 2024 15:52:00 +0000 Subject: [PATCH 017/203] DB: fix missing indexes (#2392) fixes xibosignageltd/xibo-private#575 --- db/migrations/20240227102400_missing_indexes_migration.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/migrations/20240227102400_missing_indexes_migration.php b/db/migrations/20240227102400_missing_indexes_migration.php index a62fdefdee..8ce731da97 100644 --- a/db/migrations/20240227102400_missing_indexes_migration.php +++ b/db/migrations/20240227102400_missing_indexes_migration.php @@ -33,7 +33,7 @@ public function change(): void $region = $this->table('region'); if (!$region->hasForeignKey('layoutId')) { $region - ->addForeignKey('layoutId', 'layout') + ->addForeignKey('layoutId', 'layout', 'layoutId') ->save(); } From 3811aee70e9f14c278695cef953bda3a480435d8 Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Wed, 28 Feb 2024 08:17:14 +0000 Subject: [PATCH 018/203] DB: fix missing indexes (#2393) fixes xibosignageltd/xibo-private#575 --- db/migrations/20240227102400_missing_indexes_migration.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/db/migrations/20240227102400_missing_indexes_migration.php b/db/migrations/20240227102400_missing_indexes_migration.php index 8ce731da97..0ada9fe6c0 100644 --- a/db/migrations/20240227102400_missing_indexes_migration.php +++ b/db/migrations/20240227102400_missing_indexes_migration.php @@ -32,6 +32,10 @@ public function change(): void { $region = $this->table('region'); if (!$region->hasForeignKey('layoutId')) { + // Take care of orphaned regions + $this->execute('DELETE FROM `region` WHERE `layoutId` NOT IN (SELECT `layoutId` FROM `layout`)'); + + // Add the FK $region ->addForeignKey('layoutId', 'layout', 'layoutId') ->save(); From 2cc16eea4a5510de80839566f0d2e066a52d020d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mauro=20Ferr=C3=A3o?= Date: Thu, 29 Feb 2024 16:00:53 +0000 Subject: [PATCH 019/203] Feature/editor toolbar provider tabs (#2398) * Library Search : New event for list of providers, adjusted provider filter. * Library Search : Do not dispatch the ProviderListEvent, not needed here. * Toolbar: Add Pixabay as a toolbar option instead of in images relates to xibosignageltd/xibo-private#629 --------- Co-authored-by: Peter --- lib/Connector/PixabayConnector.php | 235 +++++++++-------- lib/Connector/ProviderDetails.php | 2 + lib/Connector/XiboExchangeConnector.php | 1 + lib/Controller/Library.php | 34 ++- lib/Event/LibraryProviderEvent.php | 11 +- lib/Event/LibraryProviderListEvent.php | 57 +++++ lib/routes-web.php | 3 + ui/src/editor-core/toolbar.js | 241 +++++++++++++----- ui/src/style/toolbar.scss | 12 +- ui/src/style/variables.scss | 1 + ui/src/templates/toolbar-search-form.hbs | 4 +- ui/src/templates/toolbar.hbs | 8 +- views/common.twig | 6 + .../img/connectors/pixabay_logo_square.svg | 17 ++ 14 files changed, 445 insertions(+), 187 deletions(-) create mode 100644 lib/Event/LibraryProviderListEvent.php create mode 100644 web/theme/default/img/connectors/pixabay_logo_square.svg diff --git a/lib/Connector/PixabayConnector.php b/lib/Connector/PixabayConnector.php index 120560eecd..49f9d40e5c 100644 --- a/lib/Connector/PixabayConnector.php +++ b/lib/Connector/PixabayConnector.php @@ -27,6 +27,7 @@ use Xibo\Entity\SearchResult; use Xibo\Event\LibraryProviderEvent; use Xibo\Event\LibraryProviderImportEvent; +use Xibo\Event\LibraryProviderListEvent; use Xibo\Support\Sanitizer\SanitizerInterface; /** @@ -41,6 +42,7 @@ public function registerWithDispatcher(EventDispatcherInterface $dispatcher): Co { $dispatcher->addListener('connector.provider.library', [$this, 'onLibraryProvider']); $dispatcher->addListener('connector.provider.library.import', [$this, 'onLibraryImport']); + $dispatcher->addListener('connector.provider.library.list', [$this, 'onLibraryList']); return $this; } @@ -98,127 +100,131 @@ public function onLibraryProvider(LibraryProviderEvent $event) return; } - // We do! Let's get some results from Pixabay - // first we look at paging - $start = $event->getStart(); - $perPage = $event->getLength(); - if ($start == 0) { - $page = 1; - } else { - $page = floor($start / $perPage) + 1; - } + // was Pixabay requested? + if ($event->getProviderName() === $this->getSourceName()) { + // We do! Let's get some results from Pixabay + // first we look at paging + $start = $event->getStart(); + $perPage = $event->getLength(); + if ($start == 0) { + $page = 1; + } else { + $page = floor($start / $perPage) + 1; + } - $query = [ - 'key' => $apiKey, - 'page' => $page, - 'per_page' => $perPage, - 'safesearch' => 'true' - ]; - - // Now we handle any other search - if ($event->getOrientation() === 'landscape') { - $query['orientation'] = 'horizontal'; - } else if ($event->getOrientation() === 'portrait') { - $query['orientation'] = 'vertical'; - } + $query = [ + 'key' => $apiKey, + 'page' => $page, + 'per_page' => $perPage, + 'safesearch' => 'true' + ]; - if (!empty($event->getSearch())) { - $query['q'] = urlencode($event->getSearch()); - } + // Now we handle any other search + if ($event->getOrientation() === 'landscape') { + $query['orientation'] = 'horizontal'; + } else if ($event->getOrientation() === 'portrait') { + $query['orientation'] = 'vertical'; + } - // Pixabay either returns images or videos, not both. - if (count($event->getTypes()) !== 1) { - return; - } + if (!empty($event->getSearch())) { + $query['q'] = urlencode($event->getSearch()); + } - $type = $event->getTypes()[0]; - if (!in_array($type, ['image', 'video'])) { - return; - } + // Pixabay either returns images or videos, not both. + if (count($event->getTypes()) !== 1) { + return; + } - // Pixabay require a 24-hour cache of each result set. - $key = md5($type . '_' . json_encode($query)); - $cache = $this->getPool()->getItem($key); - $body = $cache->get(); + $type = $event->getTypes()[0]; + if (!in_array($type, ['image', 'video'])) { + return; + } - if ($cache->isMiss()) { - $this->getLogger()->debug('onLibraryProvider: cache miss, generating.'); + // Pixabay require a 24-hour cache of each result set. + $key = md5($type . '_' . json_encode($query)); + $cache = $this->getPool()->getItem($key); + $body = $cache->get(); - // Make the request - $request = $this->getClient()->request('GET', $baseUrl . ($type === 'video' ? 'videos' : ''), [ - 'query' => $query - ]); + if ($cache->isMiss()) { + $this->getLogger()->debug('onLibraryProvider: cache miss, generating.'); - $body = $request->getBody()->getContents(); - if (empty($body)) { - $this->getLogger()->debug('onLibraryProvider: Empty body'); - return; - } + // Make the request + $request = $this->getClient()->request('GET', $baseUrl . ($type === 'video' ? 'videos' : ''), [ + 'query' => $query + ]); - $body = json_decode($body); - if ($body === null || $body === false) { - $this->getLogger()->debug('onLibraryProvider: non-json body or empty body returned.'); - return; + $body = $request->getBody()->getContents(); + if (empty($body)) { + $this->getLogger()->debug('onLibraryProvider: Empty body'); + return; + } + + $body = json_decode($body); + if ($body === null || $body === false) { + $this->getLogger()->debug('onLibraryProvider: non-json body or empty body returned.'); + return; + } + + // Cache for next time + $cache->set($body); + $cache->expiresAt(Carbon::now()->addHours(24)); + $this->getPool()->saveDeferred($cache); + } else { + $this->getLogger()->debug('onLibraryProvider: serving from cache.'); } - // Cache for next time - $cache->set($body); - $cache->expiresAt(Carbon::now()->addHours(24)); - $this->getPool()->saveDeferred($cache); - } else { - $this->getLogger()->debug('onLibraryProvider: serving from cache.'); - } + $providerDetails = new ProviderDetails(); + $providerDetails->id = 'pixabay'; + $providerDetails->link = 'https://pixabay.com'; + $providerDetails->logoUrl = '/theme/default/img/connectors/pixabay_logo.svg'; + $providerDetails->iconUrl = '/theme/default/img/connectors/pixabay_logo_square.svg'; + $providerDetails->backgroundColor = ''; - $providerDetails = new ProviderDetails(); - $providerDetails->id = 'pixabay'; - $providerDetails->link = 'https://pixabay.com'; - $providerDetails->logoUrl = '/theme/default/img/connectors/pixabay_logo.svg'; - $providerDetails->backgroundColor = ''; + // Process each hit into a search result and add it to the overall results we've been given. + foreach ($body->hits as $result) { + $searchResult = new SearchResult(); + // TODO: add more info + $searchResult->source = $this->getSourceName(); + $searchResult->id = $result->id; + $searchResult->title = $result->tags; + $searchResult->provider = $providerDetails; - // Process each hit into a search result and add it to the overall results we've been given. - foreach ($body->hits as $result) { - $searchResult = new SearchResult(); - // TODO: add more info - $searchResult->source = $this->getSourceName(); - $searchResult->id = $result->id; - $searchResult->title = $result->tags; - $searchResult->provider = $providerDetails; - - if ($type === 'video') { - $searchResult->type = 'video'; - $searchResult->thumbnail = $result->videos->tiny->url; - $searchResult->duration = $result->duration; - $searchResult->videoThumbnailUrl = str_replace('pictureId', $result->picture_id, 'https://i.vimeocdn.com/video/pictureId_960x540.png'); - if (!empty($result->videos->large)) { - $searchResult->download = $result->videos->large->url; - $searchResult->width = $result->videos->large->width; - $searchResult->height = $result->videos->large->height; - $searchResult->fileSize = $result->videos->large->size; - } else if (!empty($result->videos->medium)) { - $searchResult->download = $result->videos->medium->url; - $searchResult->width = $result->videos->medium->width; - $searchResult->height = $result->videos->medium->height; - $searchResult->fileSize = $result->videos->medium->size; - } else if (!empty($result->videos->small)) { - $searchResult->download = $result->videos->small->url; - $searchResult->width = $result->videos->small->width; - $searchResult->height = $result->videos->small->height; - $searchResult->fileSize = $result->videos->small->size; + if ($type === 'video') { + $searchResult->type = 'video'; + $searchResult->thumbnail = $result->videos->tiny->url; + $searchResult->duration = $result->duration; + $searchResult->videoThumbnailUrl = str_replace('pictureId', $result->picture_id, 'https://i.vimeocdn.com/video/pictureId_960x540.png'); + if (!empty($result->videos->large)) { + $searchResult->download = $result->videos->large->url; + $searchResult->width = $result->videos->large->width; + $searchResult->height = $result->videos->large->height; + $searchResult->fileSize = $result->videos->large->size; + } else if (!empty($result->videos->medium)) { + $searchResult->download = $result->videos->medium->url; + $searchResult->width = $result->videos->medium->width; + $searchResult->height = $result->videos->medium->height; + $searchResult->fileSize = $result->videos->medium->size; + } else if (!empty($result->videos->small)) { + $searchResult->download = $result->videos->small->url; + $searchResult->width = $result->videos->small->width; + $searchResult->height = $result->videos->small->height; + $searchResult->fileSize = $result->videos->small->size; + } else { + $searchResult->download = $result->videos->tiny->url; + $searchResult->width = $result->videos->tiny->width; + $searchResult->height = $result->videos->tiny->height; + $searchResult->fileSize = $result->videos->tiny->size; + } } else { - $searchResult->download = $result->videos->tiny->url; - $searchResult->width = $result->videos->tiny->width; - $searchResult->height = $result->videos->tiny->height; - $searchResult->fileSize = $result->videos->tiny->size; + $searchResult->type = 'image'; + $searchResult->thumbnail = $result->previewURL; + $searchResult->download = $result->fullHDURL ?? $result->largeImageURL; + $searchResult->width = $result->imageWidth; + $searchResult->height = $result->imageHeight; + $searchResult->fileSize = $result->imageSize; } - } else { - $searchResult->type = 'image'; - $searchResult->thumbnail = $result->previewURL; - $searchResult->download = $result->fullHDURL ?? $result->largeImageURL; - $searchResult->width = $result->imageWidth; - $searchResult->height = $result->imageHeight; - $searchResult->fileSize = $result->imageSize; + $event->addResult($searchResult); } - $event->addResult($searchResult); } } @@ -234,4 +240,23 @@ public function onLibraryImport(LibraryProviderImportEvent $event) } } } + + public function onLibraryList(LibraryProviderListEvent $event) + { + $this->getLogger()->debug('onLibraryList:event'); + + if (empty($this->getSetting('apiKey'))) { + $this->getLogger()->debug('onLibraryList: No api key'); + return; + } + + $providerDetails = new ProviderDetails(); + $providerDetails->id = 'pixabay'; + $providerDetails->link = 'https://pixabay.com'; + $providerDetails->logoUrl = '/theme/default/img/connectors/pixabay_logo.svg'; + $providerDetails->iconUrl = '/theme/default/img/connectors/pixabay_logo_square.svg'; + $providerDetails->backgroundColor = ''; + + $event->addProvider($providerDetails); + } } diff --git a/lib/Connector/ProviderDetails.php b/lib/Connector/ProviderDetails.php index 8abc2313d3..0038bf4fe6 100644 --- a/lib/Connector/ProviderDetails.php +++ b/lib/Connector/ProviderDetails.php @@ -31,6 +31,7 @@ class ProviderDetails implements \JsonSerializable public $message; public $link; public $logoUrl; + public $iconUrl; public $backgroundColor; public function jsonSerialize(): array @@ -40,6 +41,7 @@ public function jsonSerialize(): array 'message' => $this->message, 'link' => $this->link, 'logoUrl' => $this->logoUrl, + 'iconUrl' => $this->iconUrl, 'backgroundColor' => $this->backgroundColor ]; } diff --git a/lib/Connector/XiboExchangeConnector.php b/lib/Connector/XiboExchangeConnector.php index 71c77540db..111d0574d7 100644 --- a/lib/Connector/XiboExchangeConnector.php +++ b/lib/Connector/XiboExchangeConnector.php @@ -132,6 +132,7 @@ public function onTemplateProvider(TemplateProviderEvent $event) $providerDetails = new ProviderDetails(); $providerDetails->id = $this->getSourceName(); $providerDetails->logoUrl = $this->getThumbnail(); + $providerDetails->iconUrl = $this->getThumbnail(); $providerDetails->message = $this->getTitle(); $providerDetails->backgroundColor = ''; diff --git a/lib/Controller/Library.php b/lib/Controller/Library.php index 7c212f37ad..871f4bf321 100644 --- a/lib/Controller/Library.php +++ b/lib/Controller/Library.php @@ -25,6 +25,7 @@ use GuzzleHttp\Client; use Illuminate\Support\Str; use Intervention\Image\ImageManagerStatic as Img; +use Psr\Http\Message\ResponseInterface; use Respect\Validation\Validator as v; use Slim\Http\Response as Response; use Slim\Http\ServerRequest as Request; @@ -36,6 +37,7 @@ use Xibo\Entity\SearchResults; use Xibo\Event\LibraryProviderEvent; use Xibo\Event\LibraryProviderImportEvent; +use Xibo\Event\LibraryProviderListEvent; use Xibo\Event\MediaDeleteEvent; use Xibo\Event\MediaFullLoadEvent; use Xibo\Factory\DisplayFactory; @@ -816,10 +818,10 @@ public function grid(Request $request, Response $response) public function search(Request $request, Response $response): Response { $parsedQueryParams = $this->getSanitizer($request->getQueryParams()); - $provider = $parsedQueryParams->getString('provider', ['default' => 'both']); + $provider = $parsedQueryParams->getString('provider', ['default' => 'local']); $searchResults = new SearchResults(); - if ($provider === 'both' || $provider === 'local') { + if ($provider === 'local') { // Sorting options. // only allow from a preset list $sortCol = match ($parsedQueryParams->getString('sortCol')) { @@ -876,10 +878,8 @@ public function search(Request $request, Response $response): Response // Add the result $searchResults->data[] = $searchResult; } - } - - if ($provider === 'both' || $provider === 'remote') { - $this->getLog()->debug('Dispatching event.'); + } else { + $this->getLog()->debug('Dispatching event, for provider ' . $provider); // Do we have a type filter $types = $parsedQueryParams->getArray('types'); @@ -895,11 +895,12 @@ public function search(Request $request, Response $response): Response $parsedQueryParams->getInt('length', ['default' => 10]), $parsedQueryParams->getString('media'), $types, - $parsedQueryParams->getString('orientation') + $parsedQueryParams->getString('orientation'), + $provider ); try { - $this->getDispatcher()->dispatch($event->getName(), $event); + $this->getDispatcher()->dispatch($event, $event->getName()); } catch (\Exception $exception) { $this->getLog()->error('Library search: Exception in dispatched event: ' . $exception->getMessage()); $this->getLog()->debug($exception->getTraceAsString()); @@ -909,6 +910,23 @@ public function search(Request $request, Response $response): Response return $response->withJson($searchResults); } + /** + * Get list of Library providers with their details. + * + * @param Request $request + * @param Response $response + * @return Response|ResponseInterface + */ + public function providersList(Request $request, Response $response): Response|\Psr\Http\Message\ResponseInterface + { + $event = new LibraryProviderListEvent(); + $this->getDispatcher()->dispatch($event, $event->getName()); + + $providers = $event->getProviders(); + + return $response->withJson($providers); + } + /** * Media Delete Form * @param Request $request diff --git a/lib/Event/LibraryProviderEvent.php b/lib/Event/LibraryProviderEvent.php index dec770b3fe..8af84e71d5 100644 --- a/lib/Event/LibraryProviderEvent.php +++ b/lib/Event/LibraryProviderEvent.php @@ -49,6 +49,8 @@ class LibraryProviderEvent extends Event /** @var string landspace|portrait or empty */ private $orientation; + /** @var string provider name */ + private $provider; /** * @param \Xibo\Entity\SearchResults $results @@ -57,8 +59,9 @@ class LibraryProviderEvent extends Event * @param $search * @param $types * @param $orientation + * @param $provider */ - public function __construct(SearchResults $results, $start, $length, $search, $types, $orientation) + public function __construct(SearchResults $results, $start, $length, $search, $types, $orientation, $provider) { $this->results = $results; $this->start = $start; @@ -66,6 +69,7 @@ public function __construct(SearchResults $results, $start, $length, $search, $t $this->search = $search; $this->types = $types; $this->orientation = $orientation; + $this->provider = $provider; } public function addResult(SearchResult $result): LibraryProviderEvent @@ -120,4 +124,9 @@ public function getOrientation() { return $this->orientation; } + + public function getProviderName() + { + return $this->provider; + } } diff --git a/lib/Event/LibraryProviderListEvent.php b/lib/Event/LibraryProviderListEvent.php new file mode 100644 index 0000000000..b9d3a4ced9 --- /dev/null +++ b/lib/Event/LibraryProviderListEvent.php @@ -0,0 +1,57 @@ +. + */ + +namespace Xibo\Event; + +use Xibo\Connector\ProviderDetails; + +class LibraryProviderListEvent extends Event +{ + protected static $NAME = 'connector.provider.library.list'; + /** + * @var array + */ + private mixed $providers; + + public function __construct($providers = []) + { + $this->providers = $providers; + } + + /** + * @param ProviderDetails $provider + * @return LibraryProviderListEvent + */ + public function addProvider(ProviderDetails $provider): LibraryProviderListEvent + { + $this->providers[] = $provider; + return $this; + } + + /** + * @return ProviderDetails[] + */ + public function getProviders(): array + { + return $this->providers; + } +} diff --git a/lib/routes-web.php b/lib/routes-web.php index 737f505c15..be35c0e5e2 100644 --- a/lib/routes-web.php +++ b/lib/routes-web.php @@ -237,6 +237,9 @@ $app->get('/library/search', ['\Xibo\Controller\Library','search']) ->setName('library.search.all'); +$app->get('/library/connector/list', ['\Xibo\Controller\Library','providersList']) + ->setName('library.search.providers'); + $app->get('/library/view', ['\Xibo\Controller\Library','displayPage']) ->addMiddleware(new FeatureAuth($app->getContainer(), ['library.view'])) ->setName('library.view'); diff --git a/ui/src/editor-core/toolbar.js b/ui/src/editor-core/toolbar.js index 246cfb5db2..ce28a5649f 100644 --- a/ui/src/editor-core/toolbar.js +++ b/ui/src/editor-core/toolbar.js @@ -66,6 +66,9 @@ const Toolbar = function( this.selectedCard = {}; + // Flag to mark if toolbar is properly initialized + this.initalized = false; + // Flag to mark if the toolbar has been rendered at least one time this.firstRun = true; @@ -83,11 +86,6 @@ const Toolbar = function( // Is the toolbar a playlist toolbar? this.isPlaylist = isPlaylist; - - // Initialize toolbar - this.init({ - isPlaylist: isPlaylist, - }); }; /** @@ -96,6 +94,8 @@ const Toolbar = function( * @param {boolean=} [options.isPlaylist] - is it a playlist toolbar? */ Toolbar.prototype.init = function({isPlaylist = false} = {}) { + const self = this; + // Modules to be used in Widgets let moduleListFiltered = []; @@ -301,32 +301,6 @@ Toolbar.prototype.init = function({isPlaylist = false} = {}) { itemCount: 0, favouriteModules: [], }, - { - name: 'playlists', - itemName: toolbarTrans.menuItems.playlistsName, - itemIcon: 'list', - itemTitle: toolbarTrans.menuItems.playlistsTitle, - contentType: 'playlists', - filters: { - name: { - value: '', - key: 'name', - }, - tag: { - value: '', - key: 'tags', - dataRole: 'tagsinput', - }, - user: { - value: '', - key: 'users', - dataRole: 'usersList', - searchUrl: urlsForApi.user.get.url, - }, - }, - state: '', - itemCount: 0, - }, { name: 'global', disabled: isPlaylist ? true : false, @@ -372,9 +346,6 @@ Toolbar.prototype.init = function({isPlaylist = false} = {}) { orientation: { value: '', }, - provider: { - value: 'both', - }, }, sort: { mediaId: 'numeric', @@ -418,9 +389,6 @@ Toolbar.prototype.init = function({isPlaylist = false} = {}) { dataRole: 'usersList', searchUrl: urlsForApi.user.get.url, }, - provider: { - value: 'both', - }, }, sort: { mediaId: 'numeric', @@ -464,9 +432,6 @@ Toolbar.prototype.init = function({isPlaylist = false} = {}) { orientation: { value: '', }, - provider: { - value: 'both', - }, }, sort: { mediaId: 'numeric', @@ -510,9 +475,6 @@ Toolbar.prototype.init = function({isPlaylist = false} = {}) { dataRole: 'usersList', searchUrl: urlsForApi.user.get.url, }, - provider: { - value: 'both', - }, }, sort: { mediaId: 'numeric', @@ -527,6 +489,32 @@ Toolbar.prototype.init = function({isPlaylist = false} = {}) { state: '', itemCount: 0, }, + { + name: 'playlists', + itemName: toolbarTrans.menuItems.playlistsName, + itemIcon: 'list', + itemTitle: toolbarTrans.menuItems.playlistsTitle, + contentType: 'playlists', + filters: { + name: { + value: '', + key: 'name', + }, + tag: { + value: '', + key: 'tags', + dataRole: 'tagsinput', + }, + user: { + value: '', + key: 'users', + dataRole: 'usersList', + searchUrl: urlsForApi.user.get.url, + }, + }, + state: '', + itemCount: 0, + }, { name: 'actions', disabled: isPlaylist ? true : false, @@ -594,6 +582,81 @@ Toolbar.prototype.init = function({isPlaylist = false} = {}) { this.moduleListOtherFiltered = moduleListOtherFiltered; this.moduleListOtherTypes = moduleListOtherTypes; this.moduleGroups = moduleGroups; + + // Get providers + $.ajax(urlsForApi.media.getProviders).done(function(res) { + if ( + Array.isArray(res) + ) { + res.forEach((provider) => { + // Add provider to menu items + self.menuItems.push( + { + name: provider.id, + provider: provider.id, + itemName: provider.id, + link: provider.link, + customIcon: true, + itemTitle: toolbarTrans.menuItems + .providerTitle.replace('%obj%', provider.id), + itemIcon: provider.iconUrl, + contentType: 'media', + filters: { + name: { + value: '', + key: 'media', + }, + type: { + value: '', + hideDefault: true, + // TODO Hardcoded for now, we need to get list from provider + values: [ + { + name: 'Image', + type: 'image', + disabled: false, + }, + { + name: 'Video', + type: 'video', + disabled: false, + }, + ], + }, + orientation: { + value: '', + }, + }, + state: '', + itemCount: 0, + }); + }); + } else { + // Login Form needed? + if (res.login) { + window.location.href = window.location.href; + location.reload(); + } else { + // Just an error we dont know about + if (res.message == undefined) { + console.error(res); + } else { + console.error(res.message); + } + } + } + + // Mark as init and render + self.initalized = true; + self.render(); + }).catch(function(jqXHR, textStatus, errorThrown) { + console.error(jqXHR, textStatus, errorThrown); + console.error(errorMessagesTrans.getProvidersFailed); + + // Mark as init and render + self.initalized = true; + self.render(); + }); }; /** @@ -602,9 +665,19 @@ Toolbar.prototype.init = function({isPlaylist = false} = {}) { Toolbar.prototype.loadPrefs = function() { // Load using the API const linkToAPI = urlsForApi.user.getPref; - const app = this.parent; + const renderBars = function () { + // Render toolbar and topbar if exists + self.render({ + savePrefs: false, + }); + + if (app.topbar) { + app.topbar.render(); + } + }; + // Request items based on filters const self = this; $.ajax({ @@ -657,10 +730,12 @@ Toolbar.prototype.loadPrefs = function() { for (const filter in loadedData.filters) { if (loadedData.filters.hasOwnProperty(filter)) { const menuIdx = findMenuIndexByName(filter); - for (const filterValue in loadedData.filters[filter]) { - if (loadedData.filters[filter].hasOwnProperty(filterValue)) { - self.menuItems[menuIdx].filters[filterValue].value = - loadedData.filters[filter][filterValue]; + if (menuIdx != -1) { + for (const filterValue in loadedData.filters[filter]) { + if (loadedData.filters[filter].hasOwnProperty(filterValue)) { + self.menuItems[menuIdx].filters[filterValue].value = + loadedData.filters[filter][filterValue]; + } } } } @@ -672,10 +747,12 @@ Toolbar.prototype.loadPrefs = function() { for (const sortItem in loadedData.sort) { if (loadedData.sort.hasOwnProperty(sortItem)) { const menuIdx = findMenuIndexByName(sortItem); - self.menuItems[menuIdx].sortCol = - (loadedData.sort[sortItem].sortCol) || ''; - self.menuItems[menuIdx].sortDir = - loadedData.sort[sortItem].sortDir || 'asc'; + if (menuIdx != -1) { + self.menuItems[menuIdx].sortCol = + (loadedData.sort[sortItem].sortCol) || ''; + self.menuItems[menuIdx].sortDir = + loadedData.sort[sortItem].sortDir || 'asc'; + } } } } @@ -688,13 +765,8 @@ Toolbar.prototype.loadPrefs = function() { // Reload tooltips app.common.reloadTooltips(self.DOMObject); - // Render toolbar and topbar if exists - self.render({ - savePrefs: false, - }); - if (app.topbar) { - app.topbar.render(); - } + // Render bars + renderBars(); } else { // Login Form needed? if (res.login) { @@ -707,11 +779,17 @@ Toolbar.prototype.loadPrefs = function() { } else { console.error(res.message); } + + // Still render bars + renderBars(); } } }).catch(function(jqXHR, textStatus, errorThrown) { console.error(jqXHR, textStatus, errorThrown); toastr.error(errorMessagesTrans.userLoadPreferencesFailed); + + // Still render bars + renderBars(); }); }; @@ -836,12 +914,26 @@ Toolbar.prototype.savePrefs = _.debounce(function(clearPrefs = false) { * @param {bool=} savePrefs - Save preferences */ Toolbar.prototype.render = function({savePrefs = true} = {}) { + // If toolbar isn't initialized, do it + if (this.initalized === false) { + // Initialize toolbar + this.init({ + isPlaylist: this.isPlaylist, + }); + + // Stop running + return; + } + // Load preferences when the toolbar is rendered for the first time if (this.firstRun) { this.firstRun = false; // Load user preferences this.loadPrefs(); + + // Stop running + return; } const self = this; @@ -906,13 +998,15 @@ Toolbar.prototype.render = function({savePrefs = true} = {}) { this.DOMObject.show(); // Handle menus - for (let i = 0; i < this.menuItems.length; i++) { - const toolbar = self; - const index = i; + if (Array.isArray(this.menuItems)) { + for (let i = 0; i < this.menuItems.length; i++) { + const toolbar = self; + const index = i; - this.DOMObject.find('#btn-menu-' + index).click(function() { - toolbar.openMenu(index); - }); + this.DOMObject.find('#btn-menu-' + index).click(function() { + toolbar.openMenu(index); + }); + } } } @@ -1408,7 +1502,8 @@ Toolbar.prototype.mediaContentCreateWindow = function(menu) { sortDir: this.menuItems[menu].sortDir, trans: toolbarTrans, formClass: 'media-search-form', - showSort: true, + // Hide sort for providers + showSort: !this.menuItems[menu].provider, }); // Clear temp data @@ -1481,7 +1576,7 @@ Toolbar.prototype.mediaContentPopulate = function(menu) { // Get sort if exists const $sort = $form.find('#input-sort'); - if (self.menuItems[menu].sortCol != '') { + if ($sort.length > 0 && self.menuItems[menu].sortCol != '') { filter.sortCol = self.menuItems[menu].sortCol; const sortDir = ($sort.siblings('.sort-button-container') @@ -1489,13 +1584,17 @@ Toolbar.prototype.mediaContentPopulate = function(menu) { filter.sortDir = (sortDir) || 'DESC'; } + // Filter by provider if exists + if (self.menuItems[menu].provider) { + filter.provider = self.menuItems[menu].provider; + } + $.ajax({ url: librarySearchUrl, type: 'GET', data: $.extend({ start: start, length: requestLength, - provider: 'both', }, filter), }).done(function(res) { // Remove loading @@ -1536,7 +1635,11 @@ Toolbar.prototype.mediaContentPopulate = function(menu) { } }; - if ($mediaContent.find('.upload-card').length == 0) { + // Add upload card only if we don't have provider + if ( + $mediaContent.find('.upload-card').length == 0 && + !self.menuItems[menu].provider + ) { // If we have a specific module type if (filter.type != '') { addUploadCard(app.common.getModuleByType(filter.type)); diff --git a/ui/src/style/toolbar.scss b/ui/src/style/toolbar.scss index 94b7fa3d92..82457ecfd4 100644 --- a/ui/src/style/toolbar.scss +++ b/ui/src/style/toolbar.scss @@ -831,13 +831,23 @@ font-size: 1.5rem; padding: 10px; line-height: 30px; - max-height: $toolbar-width; + height: $toolbar-tab-menu-height; .material-icons { font-size: 1.85rem; line-height: 30px; } + &.btn-menu-option-custom-icon { + padding: 8px; + } + + .btn-menu-custom-icon { + width: 100%; + height: calc($toolbar-tab-menu-height - 16px); + margin: 0; + } + &:hover { background: $xibo-color-primary-l60; color: $xibo-color-neutral-100; diff --git a/ui/src/style/variables.scss b/ui/src/style/variables.scss index f5219e3085..a600264311 100644 --- a/ui/src/style/variables.scss +++ b/ui/src/style/variables.scss @@ -77,6 +77,7 @@ $loading-overlay-z-index: 3000; // DIMENSIONS $toolbar-width: 60px; +$toolbar-tab-menu-height: 50px; $toolbar-header-height: 50px; $toolbar-content-header-height: 40px; diff --git a/ui/src/templates/toolbar-search-form.hbs b/ui/src/templates/toolbar-search-form.hbs index 89549267dc..81e9eaac87 100644 --- a/ui/src/templates/toolbar-search-form.hbs +++ b/ui/src/templates/toolbar-search-form.hbs @@ -7,7 +7,9 @@ {{#if this.locked}} {{else}} - + {{#unless hideDefault}} + + {{/unless}} {{#each this.values}} diff --git a/ui/src/templates/toolbar.hbs b/ui/src/templates/toolbar.hbs index 402cd8e25f..0e62751751 100644 --- a/ui/src/templates/toolbar.hbs +++ b/ui/src/templates/toolbar.hbs @@ -4,9 +4,13 @@
diff --git a/views/schedule-page.twig b/views/schedule-page.twig index a045bbdec4..713320cd74 100644 --- a/views/schedule-page.twig +++ b/views/schedule-page.twig @@ -234,6 +234,7 @@ {% trans 'Recurrence Repeats On' %} {% trans 'Recurrence End' %} {% trans 'Priority?' %} + {% trans 'Criteria?' %} {% trans 'Created On' %} {% trans 'Updated On' %} {% trans 'Modified By' %} @@ -1534,6 +1535,18 @@ className: 'align-middle', responsivePriority: 2, }, + { + data: 'criteria', + className: 'align-middle', + responsivePriority: 2, + data: function (data, type, row) { + if (data.criteria && data.criteria.length > 0) { + return dataTableTickCrossColumn(1, type, row); + } else { + return ''; + } + } + }, { data: 'createdOn', className: 'align-middle', From 4b3f3f98a4eea775bd0bb77b513927617f385186 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mauro=20Ferr=C3=A3o?= Date: Fri, 8 Mar 2024 12:24:48 +0000 Subject: [PATCH 021/203] Edit single sub-playlist from Layout Editor (#2426) relates to xibosignageltd/xibo-private#639 --- ui/src/layout-editor/main.js | 46 +++++++- ui/src/layout-editor/viewer.js | 88 ++++++++++++++- ui/src/playlist-editor/main.js | 106 +++++++++++++++++- ui/src/style/layout-editor.scss | 6 +- ui/src/style/playlist-editor.scss | 21 ++++ ui/src/templates/topbar-playlist-switch.hbs | 7 ++ ui/src/templates/viewer-playlist-controls.hbs | 31 ++--- views/common.twig | 7 ++ views/layout-designer-page.twig | 1 + 9 files changed, 285 insertions(+), 28 deletions(-) create mode 100644 ui/src/templates/topbar-playlist-switch.hbs diff --git a/ui/src/layout-editor/main.js b/ui/src/layout-editor/main.js index c20e78c7f4..b415568cfb 100644 --- a/ui/src/layout-editor/main.js +++ b/ui/src/layout-editor/main.js @@ -2893,8 +2893,19 @@ lD.checkLayoutStatus = function() { * New open playlist editor * @param {string} playlistId - Id of the playlist * @param {object} region - Region related to the playlist + * @param {boolean} regionSpecific + * @param {boolean} showPlaylistSwitch + * @param {boolean} switchStatus + * @param {string} auxPlaylistId - Id of the parent/child playlist */ -lD.openPlaylistEditor = function(playlistId, region) { +lD.openPlaylistEditor = function( + playlistId, + region, + regionSpecific = true, + showPlaylistSwitch = false, + switchStatus = false, + auxPlaylistId, +) { // Deselect previous selected object lD.selectObject(); @@ -2937,7 +2948,38 @@ lD.openPlaylistEditor = function(playlistId, region) { $playlistEditorPanel.removeClass('hidden'); // Load playlist editor - pE.loadEditor(true); + pE.loadEditor( + true, + regionSpecific, // Region specific? + showPlaylistSwitch, // Show playlist switch? + switchStatus, // Show switch as on? + { + on: function() { // Switch ON callback + $playlistEditorPanel.find('#playlist-editor') + .attr('playlist-id', auxPlaylistId); + lD.openPlaylistEditor( + auxPlaylistId, + region, + false, + true, + true, + playlistId, + ); + }, + off: function() { // Switch OFF callback + $playlistEditorPanel.find('#playlist-editor') + .attr('playlist-id', auxPlaylistId); + lD.openPlaylistEditor( + auxPlaylistId, + region, + true, + true, + false, + playlistId, + ); + }, + }, + ); // Mark as opened lD.playlistEditorOpened = true; diff --git a/ui/src/layout-editor/viewer.js b/ui/src/layout-editor/viewer.js index 6717e56829..5d60a0b8ce 100644 --- a/ui/src/layout-editor/viewer.js +++ b/ui/src/layout-editor/viewer.js @@ -610,7 +610,24 @@ Viewer.prototype.handleInteractions = function() { // Open playlist editor lD.openPlaylistEditor( regionObject.playlists.playlistId, - regionObject); + regionObject, + ); + }; + + const playlistInlineEditorBtnClick = function( + childPlaylistId, + regionId, + ) { + const regionObject = + lD.getObjectByTypeAndId('region', regionId); + lD.openPlaylistEditor( + childPlaylistId, + regionObject, + false, + true, + true, + regionObject.playlists.playlistId, + ); }; const playlistPreviewBtnClick = function(playlistId, direction) { @@ -706,9 +723,19 @@ Viewer.prototype.handleInteractions = function() { } else if ( $(e.target).hasClass('playlist-edit-btn') ) { - // Edit region if it's a playlist - playlistEditorBtnClick($(e.target) - .parents('.designer-region-playlist').attr('id')); + // Edit subplaylist inside playlist + if ($(e.target).hasClass('subplaylist-inline-edit-btn')) { + // Edit subplaylist inside playlist + playlistInlineEditorBtnClick( + $(e.target).data('childPlaylistId'), + $(e.target).parents('.designer-region-playlist').attr('id'), + ); + } else { + // Edit region if it's a playlist + playlistEditorBtnClick( + $(e.target).parents('.designer-region-playlist').attr('id'), + ); + } } else if ( $(e.target).hasClass('playlist-preview-paging-prev') ) { @@ -1187,13 +1214,62 @@ Viewer.prototype.renderRegion = function( region.playlistCountOfWidgets = res.extra && res.extra.countOfWidgets ? res.extra.countOfWidgets : 1; - $container.append(viewerPlaylistControlsTemplate({ + const appendOptions = { titleEdit: viewerTrans.editPlaylist, seq: region.playlistSeq, countOfWidgets: region.playlistCountOfWidgets, isEmpty: res.extra && res.extra.empty, trans: viewerTrans, - })); + canEditPlaylist: false, + }; + + // Append playlist controls using appendOptions + const appendPlaylistControls = function() { + $container.append(viewerPlaylistControlsTemplate(appendOptions)); + }; + + // If it's playslist with a single subplaylist widget + if ( + Object.keys(region.widgets).length === 1 && + Object.values(region.widgets)[0].subType === 'subplaylist' + ) { + // Get assigned subplaylists + const subplaylists = + JSON.parse( + Object.values(region.widgets)[0].getOptions().subPlaylists, + ); + + // If there's only one playlist, get permissions + if (subplaylists.length === 1) { + const subPlaylistId = subplaylists[0].playlistId; + $.ajax({ + method: 'GET', + url: urlsForApi.playlist.get.url + + '?playlistId=' + subplaylists[0].playlistId, + success: function(_res) { + // User has permissions + if (_res.data && _res.data.length > 0) { + appendOptions.canEditPlaylist = true; + appendOptions.canEditPlaylistId = subPlaylistId; + } + + // Append playlist controls + appendPlaylistControls(); + }, + error: function(_res) { + console.error(_res); + // Still append playlist controls + appendPlaylistControls(); + }, + }); + } else { + // Append playlist controls + appendPlaylistControls(); + } + } else { + // Append playlist controls + appendPlaylistControls(); + } } // If widget is selected, update moveable for the region diff --git a/ui/src/playlist-editor/main.js b/ui/src/playlist-editor/main.js index 4d07dcacd6..e7cee5b825 100644 --- a/ui/src/playlist-editor/main.js +++ b/ui/src/playlist-editor/main.js @@ -32,6 +32,10 @@ const playlistEditorExternalContainerTemplate = const messageTemplate = require('../templates/message.hbs'); const loadingTemplate = require('../templates/loading.hbs'); const contextMenuTemplate = require('../templates/context-menu.hbs'); +const topbarTemplatePlaylistEditor = + require('../templates/topbar-playlist-editor.hbs'); +const switchPlaylistsTemplate = + require('../templates/topbar-playlist-switch.hbs'); // Include modules const Playlist = require('../playlist-editor/playlist.js'); @@ -40,8 +44,6 @@ const Toolbar = require('../editor-core/toolbar.js'); const PropertiesPanel = require('../editor-core/properties-panel.js'); const HistoryManager = require('../editor-core/history-manager.js'); const TemplateManager = require('../layout-editor/template-manager.js'); -const topbarTemplatePlaylistEditor = - require('../templates/topbar-playlist-editor.hbs'); // Include CSS if (typeof lD == 'undefined') { @@ -101,8 +103,19 @@ window.pE = { /** * Load Playlist and build app structure * @param {string} inline - Is this an inline playlist editor? + * @param {boolean} regionSpecific - Is this region specific? + * @param {boolean} showPlaylistSwitch - Are we editing a child playlist? + * @param {boolean} playlistSwitchStatus - true: child, false: parent + * @param {object} switchContainerCallbacks - on and off callbacks + * - aux parent playlist id if we're editing child playlist */ -pE.loadEditor = function(inline = false) { +pE.loadEditor = function( + inline = false, + regionSpecific = true, + showPlaylistSwitch = false, + playlistSwitchStatus = false, + switchContainerCallbacks, +) { // Add class to body so we can use CSS specifically on it (!inline) && $('body').addClass('editor-opened'); @@ -120,7 +133,7 @@ pE.loadEditor = function(inline = false) { pE.regionSpecificQuery = ''; if (inline) { - pE.regionSpecificQuery = '®ionSpecific=1'; + (regionSpecific) && (pE.regionSpecificQuery = '®ionSpecific=1'); pE.mainRegion = pE.editorContainer.parents('#editor-container').data('regionObj'); pE.inline = true; @@ -238,6 +251,13 @@ pE.loadEditor = function(inline = false) { .on('click', function() { pE.close(); }); + + if (showPlaylistSwitch) { + pE.createPlaylistSwitch( + playlistSwitchStatus, + switchContainerCallbacks, + ); + } } else { // Login Form needed? if (res.login) { @@ -577,6 +597,15 @@ pE.refreshEditor = function( this.selectedObject.selected = true; } + // If we have more than one widget in the playlist + // and the playlist switch is off, remove switch + if ( + this.playlistSwitchOn === false && + Object.values(this.playlist.widgets).length != 1 + ) { + this.removePlaylistSwitch(); + } + // Remove temporary data (reloadPropertiesPanel) && this.clearTemporaryData(); @@ -1233,3 +1262,72 @@ pE.handleKeyInputs = function() { }); }; +/** + * Playlist switch between parent and child playlist + * @param {boolean} status - Is switch starting as on? + * @param {object} callbacks - on and off switch callbacks + */ +pE.createPlaylistSwitch = function( + status, + callbacks, +) { + const $topContainer = + pE.editorContainer.parents('#editor-container'); + + // Get switch container + let $switchContainer = + $topContainer.find('.playlist-switch-container'); + + // If container doesn't exist, create it + if ($switchContainer.length === 0) { + $switchContainer = $(switchPlaylistsTemplate(playlistEditorTrans)); + $switchContainer.prependTo($topContainer); + } + + // Add class to playlist editor container + // to adjust its height + $topContainer.find('#playlist-editor').addClass('playlist-switch-on'); + + // Create switch and handle callbacks + $switchContainer.find('[name="playlist-switch-input"]').bootstrapSwitch({ + state: status, + onText: playlistEditorTrans.playlistSwitch.on, + offText: playlistEditorTrans.playlistSwitch.off, + onSwitchChange: function(_e, state) { + setTimeout(() => { + if (state) { + callbacks.on(); + // Set flag as on + this.playlistSwitchOn = true; + } else { + callbacks.off(); + // Set flag as off + this.playlistSwitchOn = false; + } + }, 600); + }, + }); + + // Set flag as off + this.playlistSwitchOn = status; +}; + +/** + * Remove playlist switch + */ +pE.removePlaylistSwitch = function() { + // Get switch container + const $switchContainer = + pE.editorContainer.parents('#editor-container') + .find('.playlist-switch-container'); + + // Destroy switch + $switchContainer.find('[name="playlist-switch-input"]') + .bootstrapSwitch('destroy'); + + // Remove container + $switchContainer.remove(); + + // Set flag as off + this.playlistSwitchOn = false; +}; diff --git a/ui/src/style/layout-editor.scss b/ui/src/style/layout-editor.scss index 9fa7474467..1b1f59536c 100644 --- a/ui/src/style/layout-editor.scss +++ b/ui/src/style/layout-editor.scss @@ -463,7 +463,11 @@ body.editor-opened { } &:hover > .playlist-edit-btn { - opacity: 0.8; + opacity: 0.7; + + &:hover { + opacity: 0.9; + } } &:hover > .playlist-preview-paging { diff --git a/ui/src/style/playlist-editor.scss b/ui/src/style/playlist-editor.scss index a8e34001d8..76fc18889b 100644 --- a/ui/src/style/playlist-editor.scss +++ b/ui/src/style/playlist-editor.scss @@ -759,4 +759,25 @@ $timeline-step-height: 22px; } } } + + &.playlist-switch-on { + height: calc(100% - 40px); + } +} + +.playlist-switch-container { + background-color: $xibo-color-primary-l10; + height: 40px; + padding: 6px; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + line-height: 14px; + align-items: center; + gap: 8px; + border-radius: 4px 4px 0 0; + + .bootstrap-switch-container > * { + line-height: 16px; + } } \ No newline at end of file diff --git a/ui/src/templates/topbar-playlist-switch.hbs b/ui/src/templates/topbar-playlist-switch.hbs new file mode 100644 index 0000000000..17cf84bff6 --- /dev/null +++ b/ui/src/templates/topbar-playlist-switch.hbs @@ -0,0 +1,7 @@ +
+
+ +
+ + {{playlistSwitch.helpText}} +
\ No newline at end of file diff --git a/ui/src/templates/viewer-playlist-controls.hbs b/ui/src/templates/viewer-playlist-controls.hbs index ecc06e4c40..aeed9a0d38 100644 --- a/ui/src/templates/viewer-playlist-controls.hbs +++ b/ui/src/templates/viewer-playlist-controls.hbs @@ -1,5 +1,6 @@ -
+
{{#if isEmpty}} @@ -9,19 +10,19 @@

{{ trans.empty }}

{{else}} -
- +
+ - {{seq}}/{{countOfWidgets}} + {{seq}}/{{countOfWidgets}} - -
+ +
{{/if}} \ No newline at end of file diff --git a/views/common.twig b/views/common.twig index aa43fcbdb5..738678b052 100644 --- a/views/common.twig +++ b/views/common.twig @@ -1047,12 +1047,19 @@ duration: toolbarTrans.duration, editPlaylistTitle: "{{ "Edit Playlist - %playlistName% - "|trans }}", widgetsCount: "{{ "Widgets count"|trans }}", + editingSourcePlaylist: "{{ "Editing source playlist %playlistName% "|trans }}", zoomControls: { in: "{{ "Zoom In"|trans }}", out: "{{ "Zoom Out"|trans }}", default: "{{ "Default zoom"|trans }}", scaleMode: "{{ "Change scale mode"|trans }}", }, + playlistSwitch: { + on: "{{ "On"|trans }}", + off: "{{ "Off"|trans }}", + title: "{{ "Edit Playlist"|trans }}", + helpText: "{{ "This widget only has one playlist, switch Edit Playlist mode to ON and edit that playlist directly. Warning: your changes will apply anywhere this Playlist is used."|trans }}", + } }; var deleteModalTrans = { diff --git a/views/layout-designer-page.twig b/views/layout-designer-page.twig index 2cb4273322..b82992dd43 100644 --- a/views/layout-designer-page.twig +++ b/views/layout-designer-page.twig @@ -92,6 +92,7 @@ next: '{{ "Next Widget"|trans }}', empty: '{{ "Empty Playlist"|trans }}', invalidRegion: '{{ "Invalid Region"|trans }}', + editPlaylistTitle: '{{ "Edit Playlist"|trans }}', }; var timelineTrans = { From ebcf0637bc13ef5974df7f8e692a719efa6a3055 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mauro=20Ferr=C3=A3o?= Date: Fri, 8 Mar 2024 12:56:50 +0000 Subject: [PATCH 022/203] Editor toolbar: Fix for removed filter preference on load (#2402) relates to xibosignageltd/xibo-private#629 --- ui/src/editor-core/toolbar.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ui/src/editor-core/toolbar.js b/ui/src/editor-core/toolbar.js index ce28a5649f..5b13b7f7e8 100644 --- a/ui/src/editor-core/toolbar.js +++ b/ui/src/editor-core/toolbar.js @@ -667,7 +667,7 @@ Toolbar.prototype.loadPrefs = function() { const linkToAPI = urlsForApi.user.getPref; const app = this.parent; - const renderBars = function () { + const renderBars = function() { // Render toolbar and topbar if exists self.render({ savePrefs: false, @@ -732,7 +732,10 @@ Toolbar.prototype.loadPrefs = function() { const menuIdx = findMenuIndexByName(filter); if (menuIdx != -1) { for (const filterValue in loadedData.filters[filter]) { - if (loadedData.filters[filter].hasOwnProperty(filterValue)) { + if ( + loadedData.filters[filter].hasOwnProperty(filterValue) && + self.menuItems[menuIdx].filters[filterValue] != undefined + ) { self.menuItems[menuIdx].filters[filterValue].value = loadedData.filters[filter][filterValue]; } From 42082d800cb7d73acdce1260ad5c5021abefe104 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mauro=20Ferr=C3=A3o?= Date: Fri, 15 Mar 2024 09:07:30 +0000 Subject: [PATCH 023/203] Fix JS error and moveable when adding from playlist tab (#2432) relates to xibosignageltd/xibo-private#639 --- ui/src/layout-editor/viewer.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ui/src/layout-editor/viewer.js b/ui/src/layout-editor/viewer.js index 5d60a0b8ce..ba5f8c34dd 100644 --- a/ui/src/layout-editor/viewer.js +++ b/ui/src/layout-editor/viewer.js @@ -1231,7 +1231,8 @@ Viewer.prototype.renderRegion = function( // If it's playslist with a single subplaylist widget if ( Object.keys(region.widgets).length === 1 && - Object.values(region.widgets)[0].subType === 'subplaylist' + Object.values(region.widgets)[0].subType === 'subplaylist' && + Object.values(region.widgets)[0].getOptions().subPlaylists ) { // Get assigned subplaylists const subplaylists = @@ -1521,11 +1522,13 @@ Viewer.prototype.updateRegion = function( // If region is selected, but not on the container, do it if (region.selected && !$container.hasClass('selected')) { lD.viewer.selectObject($container); - lD.viewer.updateMoveable(); // Update bottom bar lD.bottombar.render(region); } + + // Always update moveable + lD.viewer.updateMoveable(); }; /** From 1aa13f5f9902e15a93ae667096c564c319560044 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mauro=20Ferr=C3=A3o?= Date: Fri, 15 Mar 2024 11:08:52 +0000 Subject: [PATCH 024/203] Template Editor: Create properties form controls (#2430) relates to xibosignageltd/xibo-private#644 --- lib/Controller/Developer.php | 3 + ui/src/style/forms.scss | 127 +++++ views/developer-template-edit-page.twig | 662 +++++++++++++++++++++++- views/developer-template-form-add.twig | 2 +- views/developer-template-page.twig | 7 - 5 files changed, 787 insertions(+), 14 deletions(-) diff --git a/lib/Controller/Developer.php b/lib/Controller/Developer.php index 39c365d1f8..1968ea4717 100644 --- a/lib/Controller/Developer.php +++ b/lib/Controller/Developer.php @@ -124,6 +124,9 @@ public function displayTemplateEditPage(Request $request, Response $response, $i $this->getState()->setData([ 'template' => $template, 'properties' => $doc->saveXML($properties), + // TODO: hardcoded for now + 'propertiesJSON' => '[]', + // 'propertiesJSON' => '[{"id":"","value":null,"type":"message","variant":"","format":"","title":"Select a dataset to display appearance options.","mode":"","target":"","propertyGroupId":"","helpText":null,"validation":null,"default":null,"options":null,"customPopOver":null,"playerCompatibility":{"windows":"v3 R303","linux":"TBC","android":"v3 R304","webos":"TBC","tizen":"TBC","message":"Fit supported from:"},"rule":[{"type":"and","message":"234","conditions":[{"field":"dataSetId","type":"gt","value":"hey"},{"field":"dataSetId3","type":"egt","value":"hey2"}]}], "visibility":[{"type":"or","message":"111","conditions":[{"field":"something","type":"eq","value":""}]}, {"type":"and","message":"222","conditions":[{"field":"something","type":"eq","value":"222"}]}],"allowLibraryRefs":false,"allowAssetRefs":false,"parseTranslations":true,"dependsOn":null},{"id":"columns","value":null,"type":"datasetColumnSelector","variant":"","format":"","title":"Item Template","mode":"","target":"","propertyGroupId":"","helpText":"Enter text in the box below, used to display each article.","validation":null,"default":null,"options":null,"customPopOver":null,"playerCompatibility":null,"visibility":[{"type":"and","message":"","conditions":[{"field":"dataSetId","type":"neq","value":""}]}],"allowLibraryRefs":false,"allowAssetRefs":false,"parseTranslations":false,"dependsOn":"dataSetId"},{"id":"noDataMessage","value":null,"type":"richText","variant":"html","format":"","title":"No data message","mode":"","target":"","propertyGroupId":"","helpText":"A message to display when no data is returned from the source","validation":null,"default":null,"options":null,"customPopOver":null,"playerCompatibility":null,"visibility":[{"type":"and","message":"","conditions":[{"field":"dataSetId","type":"neq","value":""}]}],"allowLibraryRefs":true,"allowAssetRefs":false,"parseTranslations":false,"dependsOn":null},{"id":"showHeadings","value":null,"type":"checkbox","variant":"","format":"","title":"Show the table headings?","mode":"","target":"","propertyGroupId":"","helpText":"Should the Table headings be shown?","validation":null,"default":1,"options":null,"customPopOver":null,"playerCompatibility":null,"visibility":[{"type":"and","message":"","conditions":[{"field":"dataSetId","type":"neq","value":""}]}],"allowLibraryRefs":false,"allowAssetRefs":false,"parseTranslations":false,"dependsOn":null},{"id":"rowsPerPage","value":null,"type":"number","variant":"","format":"","title":"Rows per page","mode":"","target":"","propertyGroupId":"","helpText":"Please enter the number of rows per page. 0 for no pages.","validation":null,"default":0,"options":null,"customPopOver":null,"playerCompatibility":null,"visibility":[{"type":"and","message":"","conditions":[{"field":"dataSetId","type":"neq","value":""}]}],"allowLibraryRefs":false,"allowAssetRefs":false,"parseTranslations":false,"dependsOn":null},{"id":"fontFamily","value":null,"type":"fontSelector","variant":"","format":"","title":"Font","mode":"","target":"","propertyGroupId":"","helpText":"Select a custom font - leave empty to use the default font.","validation":null,"default":null,"options":null,"customPopOver":null,"playerCompatibility":null,"visibility":[{"type":"and","message":"","conditions":[{"field":"dataSetId","type":"neq","value":""}]}],"allowLibraryRefs":false,"allowAssetRefs":false,"parseTranslations":false,"dependsOn":null},{"id":"","value":null,"type":"header","variant":"main","format":"","title":"Colours","mode":"","target":"","propertyGroupId":"","helpText":null,"validation":null,"default":null,"options":null,"customPopOver":null,"playerCompatibility":null,"visibility":[{"type":"and","message":"","conditions":[{"field":"dataSetId","type":"neq","value":""}]}],"allowLibraryRefs":false,"allowAssetRefs":false,"parseTranslations":false,"dependsOn":null},{"id":"fontColor","value":null,"type":"color","variant":"","format":"","title":"Font Colour","mode":"","target":"","propertyGroupId":"","helpText":"Use the colour picker to select the font colour","validation":null,"default":"#111","options":null,"customPopOver":null,"playerCompatibility":null,"visibility":[{"type":"and","message":"","conditions":[{"field":"dataSetId","type":"neq","value":""}]}],"allowLibraryRefs":false,"allowAssetRefs":false,"parseTranslations":false,"dependsOn":null},{"id":"text","value":null,"type":"textArea","variant":"","format":"","title":"Text","mode":"","target":"","propertyGroupId":"","helpText":null,"validation":null,"default":"Text","options":null,"customPopOver":null,"playerCompatibility":null,"visibility":[],"allowLibraryRefs":false,"allowAssetRefs":false,"parseTranslations":false,"dependsOn":null},{"id":"fontFamily","value":null,"type":"fontSelector","variant":"","format":"","title":"Font Family","mode":"","target":"","propertyGroupId":"","helpText":"Select a custom font - leave empty to use the default font.","validation":null,"default":null,"options":null,"customPopOver":null,"playerCompatibility":null,"visibility":[],"allowLibraryRefs":false,"allowAssetRefs":false,"parseTranslations":false,"dependsOn":null},{"id":"fontColor","value":null,"type":"color","variant":"","format":"","title":"Font Colour","mode":"","target":"","propertyGroupId":"","helpText":null,"validation":null,"default":"%THEME_COLOR%","options":null,"customPopOver":null,"playerCompatibility":null,"visibility":[],"allowLibraryRefs":false,"allowAssetRefs":false,"parseTranslations":false,"dependsOn":null},{"id":"verticalAlign","value":null,"type":"dropdown","variant":"","format":"","title":"Vertical Align","mode":"","target":"","propertyGroupId":"","helpText":null,"validation":null,"default":"center","options":[{"name":"flex-start","image":"","set":[],"title":"Top"},{"name":"center","image":"","set":[],"title":"Middle"},{"name":"flex-end","image":"","set":[],"title":"Bottom"}],"customPopOver":null,"playerCompatibility":null,"visibility":[],"allowLibraryRefs":false,"allowAssetRefs":false,"parseTranslations":false,"dependsOn":null}]', ]); return $this->render($request, $response); diff --git a/ui/src/style/forms.scss b/ui/src/style/forms.scss index 055b30329f..20245f76d0 100644 --- a/ui/src/style/forms.scss +++ b/ui/src/style/forms.scss @@ -526,4 +526,131 @@ select[readonly].select2-hidden-accessible + .select2-container .select2-selecti select[readonly].select2-hidden-accessible + .select2-container .select2-selection__arrow, select[readonly].select2-hidden-accessible + .select2-container .select2-selection__clear { display: none; +} + +/* + developer template form controls + */ +.developer-template-controls-container { + overflow: auto; + max-height: calc(100vh - 400px); + + .developer-template-controls { + display: flex; + gap: 16px; + margin-bottom: 6px; + + .developer-template-placeholder { + width: 100%; + height: calc(100vh - 410px); + display: flex; + justify-content: center; + align-items: center; + font-size: 18px; + color: $xibo-color-primary-d60; + background-color: $xibo-color-primary-l10; + border-radius: 6px; + } + + .developer-template-control-item { + background-color: $xibo-color-primary-l20; + padding: 16px; + border-radius: 4px; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + min-width: 360px; + max-width: 360px; + + .developer-template-control-form { + display: flex; + flex-direction: column; + flex: 1; + gap: 18px; + + .xibo-form-input { + margin-bottom: 0.5rem; + + label, .input-info-container { + float: left; + } + } + + .player-compat-items { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 3px 6px; + padding: 6px; + background-color: $xibo-color-primary-l30; + border-radius: 6px; + } + + .option-item { + display: inline-flex; + align-items: center; + gap: 6px; + background-color: $xibo-color-primary-l30; + padding: 6px; + border-radius: 6px; + margin-bottom: 4px; + } + + .test-container { + .test-title, .condition-title { + font-weight: bold; + } + + .test-item { + background-color: $xibo-color-primary-l30; + border-radius: 6px; + padding: 6px; + margin-bottom: 6px; + } + + .test-item-properties { + display: flex; + gap: 6px; + } + + .condition-item { + background-color: lighten($xibo-color-primary-l30, 5%); + padding: 6px; + border-radius: 8px; + margin-bottom: 6px; + } + + .test-conditions-container { + margin: 6px 0 6px 34px; + } + + .item-header { + display: flex; + justify-content: space-between; + margin-bottom: 6px; + } + } + } + + .developer-template-control-item-controls { + width: 100%; + display: flex; + justify-content: space-between; + + .item-move { + cursor: col-resize; + color: $xibo-color-primary-l60; + } + + .delete-btn { + padding: 0; + color: $xibo-color-semantic-error; + } + + .item-move, .delete-btn { + font-size: 24px; + } + } + } + } } \ No newline at end of file diff --git a/views/developer-template-edit-page.twig b/views/developer-template-edit-page.twig index 5be86e364c..01ca60cb90 100644 --- a/views/developer-template-edit-page.twig +++ b/views/developer-template-edit-page.twig @@ -106,22 +106,39 @@ {{ forms.checkbox("enabled", title, template.isEnabled, helpText) }}
-
+
{{ "Properties"|trans }} - + -
-
+
+ + + +
+
+
+
+
+ + +
+
{{ "Twig"|trans }} - +
@@ -133,7 +150,7 @@
{{ "HBS"|trans }} - +
@@ -201,4 +218,637 @@
+{% endblock %} + +{% block javaScript %} + {% verbatim %} + + + + + + + + + + + + + + + + + + {% endverbatim %} + + {% endblock %} \ No newline at end of file diff --git a/views/developer-template-form-add.twig b/views/developer-template-form-add.twig index 2fb45c3762..693b600bd1 100644 --- a/views/developer-template-form-add.twig +++ b/views/developer-template-form-add.twig @@ -43,7 +43,7 @@ {% set title %}{% trans "ID" %}{% endset %} {% set helpText %}{% trans "A unique ID for the module template" %}{% endset %} - {{ forms.input("templateId", title, "custom-", helpText) }} + {{ forms.input("templateId", title, "custom_", helpText) }} {% set title %}{% trans "Data Type" %}{% endset %} {% set helpText %}{% trans "Which data type does this template need?" %}{% endset %} diff --git a/views/developer-template-page.twig b/views/developer-template-page.twig index 7c0640e9fa..42032bfb57 100644 --- a/views/developer-template-page.twig +++ b/views/developer-template-page.twig @@ -110,12 +110,5 @@ $('#refreshGrid').click(function () { table.ajax.reload(); }); - - function moduleEditFormOpen(dialog) { - var moduleSettings = $(dialog).data('extra')['settings']; - var $targetContainer = $(dialog).find('.form-module-configure-fields') - - forms.createFields(moduleSettings, $targetContainer); - } {% endblock %} From 0858b5502db29a5f14a0e8aea602a66c906db270 Mon Sep 17 00:00:00 2001 From: Peter Date: Thu, 21 Mar 2024 12:07:39 +0000 Subject: [PATCH 025/203] Provider Details : return available mediaTypes. (#2441) --- lib/Connector/PixabayConnector.php | 5 +++-- lib/Connector/ProviderDetails.php | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/Connector/PixabayConnector.php b/lib/Connector/PixabayConnector.php index 49f9d40e5c..6c90b22ca4 100644 --- a/lib/Connector/PixabayConnector.php +++ b/lib/Connector/PixabayConnector.php @@ -1,8 +1,8 @@ logoUrl = '/theme/default/img/connectors/pixabay_logo.svg'; $providerDetails->iconUrl = '/theme/default/img/connectors/pixabay_logo_square.svg'; $providerDetails->backgroundColor = ''; + $providerDetails->mediaTypes = ['image', 'video']; $event->addProvider($providerDetails); } diff --git a/lib/Connector/ProviderDetails.php b/lib/Connector/ProviderDetails.php index 0038bf4fe6..b444bf82fa 100644 --- a/lib/Connector/ProviderDetails.php +++ b/lib/Connector/ProviderDetails.php @@ -1,6 +1,6 @@ $this->link, 'logoUrl' => $this->logoUrl, 'iconUrl' => $this->iconUrl, - 'backgroundColor' => $this->backgroundColor + 'backgroundColor' => $this->backgroundColor, + 'mediaTypes' => $this->mediaTypes ]; } } From 26fe4a748babf0837b7fd92af97cd00ec5188d3a Mon Sep 17 00:00:00 2001 From: Peter Date: Fri, 22 Mar 2024 09:33:09 +0000 Subject: [PATCH 026/203] Template Editor : add missing features. (#2437) --- bin/locale.php | 14 +- ...155800_user_module_templates_migration.php | 10 +- lib/Controller/Developer.php | 350 ++++++++++++++++-- lib/Controller/Module.php | 18 +- lib/Dependencies/Factories.php | 11 +- lib/Entity/ModuleTemplate.php | 35 +- lib/Factory/ModuleFactory.php | 55 +++ lib/Factory/ModuleTemplateFactory.php | 261 ++++++++++++- lib/Factory/UserGroupFactory.php | 7 +- lib/Listener/ModuleTemplateListener.php | 58 +++ lib/Middleware/ListenersMiddleware.php | 8 + lib/routes-web.php | 16 + ui/src/core/file-upload.js | 4 +- ui/src/core/xibo-cms.js | 1 + views/developer-template-edit-page.twig | 17 +- views/developer-template-form-add.twig | 22 +- views/developer-template-form-copy.twig | 48 +++ views/developer-template-form-delete.twig | 45 +++ views/developer-template-page.twig | 276 +++++++++++--- 19 files changed, 1141 insertions(+), 115 deletions(-) create mode 100644 lib/Listener/ModuleTemplateListener.php create mode 100644 views/developer-template-form-copy.twig create mode 100644 views/developer-template-form-delete.twig diff --git a/bin/locale.php b/bin/locale.php index 44fae9e378..486725ba49 100644 --- a/bin/locale.php +++ b/bin/locale.php @@ -95,10 +95,10 @@ function __($original) $sanitizerService = new \Xibo\Helper\SanitizerService(); // Create a new base dependency service -$baseDepenencyService = new \Xibo\Service\BaseDependenciesService(); -$baseDepenencyService->setConfig(new MockConfigService()); -$baseDepenencyService->setStore(new MockPdoStorageServiceForModuleFactory()); -$baseDepenencyService->setSanitizer($sanitizerService); +$baseDependencyService = new \Xibo\Service\BaseDependenciesService(); +$baseDependencyService->setConfig(new MockConfigService()); +$baseDependencyService->setStore(new MockPdoStorageServiceForModuleFactory()); +$baseDependencyService->setSanitizer($sanitizerService); $moduleFactory = new \Xibo\Factory\ModuleFactory( '', @@ -106,7 +106,7 @@ function __($original) $view, new MockConfigService(), ); -$moduleFactory->useBaseDependenciesService($baseDepenencyService); +$moduleFactory->useBaseDependenciesService($baseDependencyService); // Get all module $modules = $moduleFactory->getAll(); @@ -114,9 +114,9 @@ function __($original) $pool, $view, ); -$moduleTemplateFactory->useBaseDependenciesService($baseDepenencyService); +$moduleTemplateFactory->useBaseDependenciesService($baseDependencyService); // Get all module templates -$moduleTemplates = $moduleTemplateFactory->getAll(); +$moduleTemplates = $moduleTemplateFactory->getAll(null, false); // -------------- // Create translation file diff --git a/db/migrations/20231220155800_user_module_templates_migration.php b/db/migrations/20231220155800_user_module_templates_migration.php index e02fc01adb..9cc00b9b8a 100644 --- a/db/migrations/20231220155800_user_module_templates_migration.php +++ b/db/migrations/20231220155800_user_module_templates_migration.php @@ -1,6 +1,6 @@ false, 'default' => 1, ]) + ->addColumn('ownerId', 'integer') + ->addForeignKey('ownerId', 'user', 'userId') + ->save(); + + $this->table('permissionentity') + ->insert([ + ['entity' => 'Xibo\Entity\ModuleTemplate'] + ]) ->save(); } } diff --git a/lib/Controller/Developer.php b/lib/Controller/Developer.php index 1968ea4717..316ed1d0db 100644 --- a/lib/Controller/Developer.php +++ b/lib/Controller/Developer.php @@ -21,12 +21,20 @@ */ namespace Xibo\Controller; +use Psr\Http\Message\ResponseInterface; use Slim\Http\Response as Response; use Slim\Http\ServerRequest as Request; use Xibo\Factory\ModuleFactory; use Xibo\Factory\ModuleTemplateFactory; +use Xibo\Helper\SendFile; +use Xibo\Helper\UploadHandler; +use Xibo\Service\MediaService; +use Xibo\Service\UploadService; use Xibo\Support\Exception\AccessDeniedException; +use Xibo\Support\Exception\ControllerNotImplemented; +use Xibo\Support\Exception\GeneralException; use Xibo\Support\Exception\InvalidArgumentException; +use Xibo\Support\Exception\NotFoundException; /** * Class Module @@ -62,7 +70,13 @@ public function displayTemplatePage(Request $request, Response $response): Respo */ public function templateGrid(Request $request, Response $response): Response { - $templates = $this->moduleTemplateFactory->loadUserTemplates(); + $params = $this->getSanitizer($request->getParams()); + + $templates = $this->moduleTemplateFactory->loadUserTemplates($this->gridRenderSort($params), $this->gridRenderFilter([ + 'id' => $params->getInt('id'), + 'templateId' => $params->getString('templateId'), + 'dataType' => $params->getString('dataType'), + ], $params)); foreach ($templates as $template) { if ($this->isApi($request)) { @@ -71,17 +85,88 @@ public function templateGrid(Request $request, Response $response): Response $template->includeProperty('buttons'); - // Edit button - $template->buttons[] = [ - 'id' => 'template_button_edit', - 'url' => $this->urlFor($request, 'developer.templates.view.edit', ['id' => $template->id]), - 'text' => __('Edit'), - 'class' => 'XiboRedirectButton', - ]; + if ($this->getUser()->checkEditable($template) && + $this->getUser()->featureEnabled('developer.edit') + ) { + // Edit button + $template->buttons[] = [ + 'id' => 'template_button_edit', + 'url' => $this->urlFor($request, 'developer.templates.view.edit', ['id' => $template->id]), + 'text' => __('Edit'), + 'class' => 'XiboRedirectButton', + ]; + + $template->buttons[] = [ + 'id' => 'template_button_export', + 'linkType' => '_self', 'external' => true, + 'url' => $this->urlFor($request, 'developer.templates.export', ['id' => $template->id]), + 'text' => __('Export XML'), + ]; + + $template->buttons[] = [ + 'id' => 'template_button_copy', + 'url' => $this->urlFor($request, 'developer.templates.form.copy', ['id' => $template->id]), + 'text' => __('Copy'), + ]; + } + + if ($this->getUser()->featureEnabled('developer.edit') && + $this->getUser()->checkPermissionsModifyable($template) + ) { + $template->buttons[] = ['divider' => true]; + // Permissions for Campaign + $template->buttons[] = [ + 'id' => 'template_button_permissions', + 'url' => $this->urlFor($request,'user.permissions.form', ['entity' => 'ModuleTemplate', 'id' => $template->id]), + 'text' => __('Share'), + 'multi-select' => true, + 'dataAttributes' => [ + ['name' => 'commit-url', 'value' => $this->urlFor($request,'user.permissions.multi', ['entity' => 'ModuleTemplate', 'id' => $template->id])], + ['name' => 'commit-method', 'value' => 'post'], + ['name' => 'id', 'value' => 'template_button_permissions'], + ['name' => 'text', 'value' => __('Share')], + ['name' => 'rowtitle', 'value' => $template->templateId], + ['name' => 'sort-group', 'value' => 2], + ['name' => 'custom-handler', 'value' => 'XiboMultiSelectPermissionsFormOpen'], + ['name' => 'custom-handler-url', 'value' => $this->urlFor($request,'user.permissions.multi.form', ['entity' => 'ModuleTemplate'])], + ['name' => 'content-id-name', 'value' => 'id'] + ] + ]; + } + + if ($this->getUser()->checkDeleteable($template) && + $this->getUser()->featureEnabled('developer.delete') + ) { + $template->buttons[] = ['divider' => true]; + // Delete button + $template->buttons[] = [ + 'id' => 'template_button_delete', + 'url' => $this->urlFor($request, 'developer.templates.form.delete', ['id' => $template->id]), + 'text' => __('Delete'), + 'multi-select' => true, + 'dataAttributes' => [ + [ + 'name' => 'commit-url', + 'value' => $this->urlFor( + $request, + 'developer.templates.delete', + ['id' => $template->id] + ) + ], + ['name' => 'commit-method', 'value' => 'delete'], + ['name' => 'id', 'value' => 'template_button_delete'], + ['name' => 'text', 'value' => __('Delete')], + ['name' => 'sort-group', 'value' => 1], + ['name' => 'rowtitle', 'value' => $template->templateId] + ] + ]; + } + + } $this->getState()->template = 'grid'; - $this->getState()->recordsTotal = 0; + $this->getState()->recordsTotal = $this->moduleTemplateFactory->countLast(); $this->getState()->setData($templates); return $this->render($request, $response); @@ -115,18 +200,10 @@ public function displayTemplateEditPage(Request $request, Response $response, $i throw new AccessDeniedException(); } - //TODO: temporary extraction of properties XML (should be a form field per property instead) - $doc = $template->getDocument(); - /** @var \DOMElement $properties */ - $properties = $doc->getElementsByTagName('properties')[0]; - $this->getState()->template = 'developer-template-edit-page'; $this->getState()->setData([ 'template' => $template, - 'properties' => $doc->saveXML($properties), - // TODO: hardcoded for now - 'propertiesJSON' => '[]', - // 'propertiesJSON' => '[{"id":"","value":null,"type":"message","variant":"","format":"","title":"Select a dataset to display appearance options.","mode":"","target":"","propertyGroupId":"","helpText":null,"validation":null,"default":null,"options":null,"customPopOver":null,"playerCompatibility":{"windows":"v3 R303","linux":"TBC","android":"v3 R304","webos":"TBC","tizen":"TBC","message":"Fit supported from:"},"rule":[{"type":"and","message":"234","conditions":[{"field":"dataSetId","type":"gt","value":"hey"},{"field":"dataSetId3","type":"egt","value":"hey2"}]}], "visibility":[{"type":"or","message":"111","conditions":[{"field":"something","type":"eq","value":""}]}, {"type":"and","message":"222","conditions":[{"field":"something","type":"eq","value":"222"}]}],"allowLibraryRefs":false,"allowAssetRefs":false,"parseTranslations":true,"dependsOn":null},{"id":"columns","value":null,"type":"datasetColumnSelector","variant":"","format":"","title":"Item Template","mode":"","target":"","propertyGroupId":"","helpText":"Enter text in the box below, used to display each article.","validation":null,"default":null,"options":null,"customPopOver":null,"playerCompatibility":null,"visibility":[{"type":"and","message":"","conditions":[{"field":"dataSetId","type":"neq","value":""}]}],"allowLibraryRefs":false,"allowAssetRefs":false,"parseTranslations":false,"dependsOn":"dataSetId"},{"id":"noDataMessage","value":null,"type":"richText","variant":"html","format":"","title":"No data message","mode":"","target":"","propertyGroupId":"","helpText":"A message to display when no data is returned from the source","validation":null,"default":null,"options":null,"customPopOver":null,"playerCompatibility":null,"visibility":[{"type":"and","message":"","conditions":[{"field":"dataSetId","type":"neq","value":""}]}],"allowLibraryRefs":true,"allowAssetRefs":false,"parseTranslations":false,"dependsOn":null},{"id":"showHeadings","value":null,"type":"checkbox","variant":"","format":"","title":"Show the table headings?","mode":"","target":"","propertyGroupId":"","helpText":"Should the Table headings be shown?","validation":null,"default":1,"options":null,"customPopOver":null,"playerCompatibility":null,"visibility":[{"type":"and","message":"","conditions":[{"field":"dataSetId","type":"neq","value":""}]}],"allowLibraryRefs":false,"allowAssetRefs":false,"parseTranslations":false,"dependsOn":null},{"id":"rowsPerPage","value":null,"type":"number","variant":"","format":"","title":"Rows per page","mode":"","target":"","propertyGroupId":"","helpText":"Please enter the number of rows per page. 0 for no pages.","validation":null,"default":0,"options":null,"customPopOver":null,"playerCompatibility":null,"visibility":[{"type":"and","message":"","conditions":[{"field":"dataSetId","type":"neq","value":""}]}],"allowLibraryRefs":false,"allowAssetRefs":false,"parseTranslations":false,"dependsOn":null},{"id":"fontFamily","value":null,"type":"fontSelector","variant":"","format":"","title":"Font","mode":"","target":"","propertyGroupId":"","helpText":"Select a custom font - leave empty to use the default font.","validation":null,"default":null,"options":null,"customPopOver":null,"playerCompatibility":null,"visibility":[{"type":"and","message":"","conditions":[{"field":"dataSetId","type":"neq","value":""}]}],"allowLibraryRefs":false,"allowAssetRefs":false,"parseTranslations":false,"dependsOn":null},{"id":"","value":null,"type":"header","variant":"main","format":"","title":"Colours","mode":"","target":"","propertyGroupId":"","helpText":null,"validation":null,"default":null,"options":null,"customPopOver":null,"playerCompatibility":null,"visibility":[{"type":"and","message":"","conditions":[{"field":"dataSetId","type":"neq","value":""}]}],"allowLibraryRefs":false,"allowAssetRefs":false,"parseTranslations":false,"dependsOn":null},{"id":"fontColor","value":null,"type":"color","variant":"","format":"","title":"Font Colour","mode":"","target":"","propertyGroupId":"","helpText":"Use the colour picker to select the font colour","validation":null,"default":"#111","options":null,"customPopOver":null,"playerCompatibility":null,"visibility":[{"type":"and","message":"","conditions":[{"field":"dataSetId","type":"neq","value":""}]}],"allowLibraryRefs":false,"allowAssetRefs":false,"parseTranslations":false,"dependsOn":null},{"id":"text","value":null,"type":"textArea","variant":"","format":"","title":"Text","mode":"","target":"","propertyGroupId":"","helpText":null,"validation":null,"default":"Text","options":null,"customPopOver":null,"playerCompatibility":null,"visibility":[],"allowLibraryRefs":false,"allowAssetRefs":false,"parseTranslations":false,"dependsOn":null},{"id":"fontFamily","value":null,"type":"fontSelector","variant":"","format":"","title":"Font Family","mode":"","target":"","propertyGroupId":"","helpText":"Select a custom font - leave empty to use the default font.","validation":null,"default":null,"options":null,"customPopOver":null,"playerCompatibility":null,"visibility":[],"allowLibraryRefs":false,"allowAssetRefs":false,"parseTranslations":false,"dependsOn":null},{"id":"fontColor","value":null,"type":"color","variant":"","format":"","title":"Font Colour","mode":"","target":"","propertyGroupId":"","helpText":null,"validation":null,"default":"%THEME_COLOR%","options":null,"customPopOver":null,"playerCompatibility":null,"visibility":[],"allowLibraryRefs":false,"allowAssetRefs":false,"parseTranslations":false,"dependsOn":null},{"id":"verticalAlign","value":null,"type":"dropdown","variant":"","format":"","title":"Vertical Align","mode":"","target":"","propertyGroupId":"","helpText":null,"validation":null,"default":"center","options":[{"name":"flex-start","image":"","set":[],"title":"Top"},{"name":"center","image":"","set":[],"title":"Middle"},{"name":"flex-end","image":"","set":[],"title":"Bottom"}],"customPopOver":null,"playerCompatibility":null,"visibility":[],"allowLibraryRefs":false,"allowAssetRefs":false,"parseTranslations":false,"dependsOn":null}]', + 'propertiesJSON' => json_encode($template->properties), ]); return $this->render($request, $response); @@ -150,8 +227,21 @@ public function templateAdd(Request $request, Response $response): Response throw new InvalidArgumentException(__('Please supply a data type'), 'dataType'); }]); - // The most basic template possible. - $template = $this->moduleTemplateFactory->createUserTemplate(' + // do we have a template selected? + if (!empty($params->getString('copyTemplateId'))) { + $copyTemplate = $this->moduleTemplateFactory->getByDataTypeAndId($dataType, $params->getString('copyTemplateId')); + $xml = new \DOMDocument(); + $xml->loadXML($copyTemplate->getXml()); + $templateNode = $xml->getElementsByTagName('template')[0]; + $this->setNode($xml, 'id', $templateId, false, $templateNode); + + $template = $this->moduleTemplateFactory->createUserTemplate( + '' . + $xml->saveXML($templateNode) + ); + } else { + // The most basic template possible. + $template = $this->moduleTemplateFactory->createUserTemplate(' '); + } + $template->ownerId = $this->getUser()->userId; $template->save(); $this->getState()->hydrate([ @@ -207,6 +299,9 @@ public function templateEdit(Request $request, Response $response, $id): Respons $document = $template->getDocument(); // Root nodes + $this->setNode($document, 'id', $templateId, false); + $template->templateId = $templateId; + $this->setNode($document, 'dataType', $dataType, false); $this->setNode($document, 'onTemplateRender', $onTemplateRender); $this->setNode($document, 'onTemplateVisible', $onTemplateVisible); @@ -214,7 +309,7 @@ public function templateEdit(Request $request, Response $response, $id): Respons $stencilNodes = $document->getElementsByTagName('stencil'); if ($stencilNodes->count() <= 0) { $stencilNode = $document->createElement('stencil'); - $document->appendChild($stencilNode); + $document->documentElement->appendChild($stencilNode); } else { $stencilNode = $stencilNodes[0]; } @@ -225,23 +320,22 @@ public function templateEdit(Request $request, Response $response, $id): Respons $this->setNode($document, 'head', $head, true, $stencilNode); // Properties. - // TODO: this is temporary pending a properties UI // this is different because we want to replace the properties node with a new one. if (!empty($properties)) { - $newProperties = new \DOMDocument(); - $newProperties->loadXML($properties); - - // Do we have new nodes to import? - if ($newProperties->childNodes->count() > 0) { - $importedPropteries = $document->importNode($newProperties->documentElement, true); - if ($importedPropteries !== false) { - $propertiesNodes = $document->getElementsByTagName('properties'); - if ($propertiesNodes->count() <= 0) { - $document->appendChild($importedPropteries); - } else { - $document->documentElement->replaceChild($importedPropteries, $propertiesNodes[0]); - } - } + // parse json and create a new properties node + $newPropertiesXml = $this->moduleTemplateFactory->parseJsonPropertiesToXml($properties); + + $propertiesNodes = $document->getElementsByTagName('properties'); + + if ($propertiesNodes->count() <= 0) { + $document->documentElement->appendChild( + $document->importNode($newPropertiesXml->documentElement, true) + ); + } else { + $document->documentElement->replaceChild( + $document->importNode($newPropertiesXml->documentElement, true), + $propertiesNodes[0] + ); } } @@ -309,4 +403,188 @@ private function setNode( } } } + + public function getAvailableDataTypes(Request $request, Response $response) + { + $params = $this->getSanitizer($request->getParams()); + $dataTypes = $this->moduleFactory->getAllDataTypes(); + + if ($params->getString('dataType') !== null) { + foreach ($dataTypes as $dataType) { + if ($dataType->id === $params->getString('dataType')) { + $dataTypes = [$dataType]; + } + } + } + + $this->getState()->template = 'grid'; + $this->getState()->recordsTotal = 0; + $this->getState()->setData($dataTypes); + + return $this->render($request, $response); + } + + /** + * @param Request $request + * @param Response $response + * @param $id + * @return ResponseInterface|Response + * @throws AccessDeniedException + * @throws ControllerNotImplemented + * @throws GeneralException + * @throws NotFoundException + */ + public function templateExport(Request $request, Response $response, $id): Response|ResponseInterface + { + $template = $this->moduleTemplateFactory->getUserTemplateById($id); + + if ($template->ownership !== 'user') { + throw new AccessDeniedException(); + } + + $tempFileName = $this->getConfig()->getSetting('LIBRARY_LOCATION') . 'temp/' . $template->templateId . '.xml'; + + $template->getDocument()->save($tempFileName); + + $this->setNoOutput(true); + + return $this->render($request, SendFile::decorateResponse( + $response, + $this->getConfig()->getSetting('SENDFILE_MODE'), + $tempFileName, + $template->templateId . '.xml' + )->withHeader('Content-Type', 'text/xml;charset=utf-8')); + } + + public function templateImport(Request $request, Response $response) + { + $this->getLog()->debug('Import Module Template'); + + $libraryFolder = $this->getConfig()->getSetting('LIBRARY_LOCATION'); + + // Make sure the library exists + MediaService::ensureLibraryExists($libraryFolder); + + $options = [ + 'upload_dir' => $libraryFolder . 'temp/', + 'script_url' => $this->urlFor($request,'developer.templates.import'), + 'upload_url' => $this->urlFor($request,'developer.templates.import'), + 'accept_file_types' => '/\.xml/i', + 'libraryQuotaFull' => false, + ]; + + $this->getLog()->debug('Hand off to Upload Handler with options: ' . json_encode($options)); + + // Hand off to the Upload Handler provided by jquery-file-upload + $uploadService = new UploadService($options, $this->getLog(), $this->getState()); + $uploadHandler = $uploadService->createUploadHandler(); + + $uploadHandler->setPostProcessor(function ($file, $uploadHandler) { + // Return right away if the file already has an error. + if (!empty($file->error)) { + return $file; + } + + $this->getUser()->isQuotaFullByUser(true); + + /** @var UploadHandler $uploadHandler */ + $filePath = $uploadHandler->getUploadPath() . $file->fileName; + + // load the xml from uploaded file + $xml = new \DOMDocument(); + $xml->load($filePath); + + // Add the Template + $moduleTemplate = $this->moduleTemplateFactory->createUserTemplate($xml->saveXML()); + $moduleTemplate->ownerId = $this->getUser()->userId; + $moduleTemplate->save(); + + // Tidy up the temporary file + @unlink($filePath); + + return $file; + }); + + $uploadHandler->post(); + + $this->setNoOutput(true); + + return $this->render($request, $response); + } + + public function templateCopyForm(Request $request, Response $response, $id) + { + $moduleTemplate = $this->moduleTemplateFactory->getUserTemplateById($id); + + if (!$this->getUser()->checkViewable($moduleTemplate)) { + throw new AccessDeniedException(); + } + + $this->getState()->template = 'developer-template-form-copy'; + $this->getState()->setData([ + 'template' => $moduleTemplate, + ]); + + return $this->render($request, $response); + } + + public function templateCopy(Request $request, Response $response, $id) + { + $moduleTemplate = $this->moduleTemplateFactory->getUserTemplateById($id); + + if (!$this->getUser()->checkViewable($moduleTemplate)) { + throw new AccessDeniedException(); + } + + $params = $this->getSanitizer($request->getParams()); + + $newTemplate = clone $moduleTemplate; + $newTemplate->templateId = $params->getString('templateId'); + $newTemplate->save(); + + // Return + $this->getState()->hydrate([ + 'httpStatus' => 201, + 'message' => sprintf(__('Copied as %s'), $newTemplate->templateId), + 'id' => $newTemplate->id, + 'data' => $newTemplate + ]); + + return $this->render($request, $response); + } + + public function templateDeleteForm(Request $request, Response $response, $id) + { + $moduleTemplate = $this->moduleTemplateFactory->getUserTemplateById($id); + + if (!$this->getUser()->checkDeleteable($moduleTemplate)) { + throw new AccessDeniedException(); + } + + $this->getState()->template = 'developer-template-form-delete'; + $this->getState()->setData([ + 'template' => $moduleTemplate, + ]); + + return $this->render($request, $response); + } + + public function templateDelete(Request $request, Response $response, $id) + { + $moduleTemplate = $this->moduleTemplateFactory->getUserTemplateById($id); + + if (!$this->getUser()->checkDeleteable($moduleTemplate)) { + throw new AccessDeniedException(); + } + + $moduleTemplate->delete(); + + // Return + $this->getState()->hydrate([ + 'httpStatus' => 204, + 'message' => sprintf(__('Deleted %s'), $moduleTemplate->templateId) + ]); + + return $this->render($request, $response); + } } diff --git a/lib/Controller/Module.php b/lib/Controller/Module.php index 28bd338572..7e63afef56 100644 --- a/lib/Controller/Module.php +++ b/lib/Controller/Module.php @@ -1,6 +1,6 @@ getSanitizer($request->getParams()); + $type = $params->getString('type'); + + $templates = !empty($type) + ? $this->moduleTemplateFactory->getByTypeAndDataType($type, $dataType) + : $this->moduleTemplateFactory->getByDataType($dataType); + $this->getState()->template = 'grid'; $this->getState()->recordsTotal = 0; - $this->getState()->setData($this->moduleTemplateFactory->getByDataType($dataType)); + $this->getState()->setData($templates); return $this->render($request, $response); } diff --git a/lib/Dependencies/Factories.php b/lib/Dependencies/Factories.php index 262417f41c..d1121dfbe1 100644 --- a/lib/Dependencies/Factories.php +++ b/lib/Dependencies/Factories.php @@ -1,6 +1,6 @@ function (ContainerInterface $c) { $repository = new \Xibo\Factory\ModuleTemplateFactory( $c->get('pool'), - $c->get('view') + $c->get('view'), ); - $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService')); + $repository + ->setAclDependencies( + $c->get('user'), + $c->get('userFactory') + ) + ->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService')); return $repository; }, 'notificationFactory' => function (ContainerInterface $c) { diff --git a/lib/Entity/ModuleTemplate.php b/lib/Entity/ModuleTemplate.php index b684f603f2..c207e727c8 100644 --- a/lib/Entity/ModuleTemplate.php +++ b/lib/Entity/ModuleTemplate.php @@ -157,7 +157,16 @@ class ModuleTemplate implements \JsonSerializable /** @var string $ownership Who owns this file? system|custom|user */ public $ownership; + /** @var int $ownerId User ID of the owner of this template */ + public $ownerId; + + /** + * @SWG\Property(description="A comma separated list of groups/users with permissions to this template") + * @var string + */ + public $groupsWithPermissions; /** @var string $xml The XML used to build this template */ + private $xml; /** @var \DOMDocument The DOM Document for this templates XML */ @@ -182,9 +191,26 @@ public function __construct( private readonly string $file ) { $this->setCommonDependencies($store, $log, $dispatcher); + $this->setPermissionsClass('Xibo\Entity\ModuleTemplate'); $this->moduleTemplateFactory = $moduleTemplateFactory; } + public function getId() + { + return $this->id; + } + + public function getOwnerId() + { + return $this->ownerId; + } + + public function __clone() + { + $this->id = null; + $this->templateId = null; + } + /** * Get assets * @return \Xibo\Widget\Definition\Asset[] @@ -295,12 +321,13 @@ public function invalidate(): void private function add(): void { $this->id = $this->getStore()->insert(' - INSERT INTO `module_templates` (`templateId`, `dataType`, `xml`) - VALUES (:templateId, :dataType, :xml) + INSERT INTO `module_templates` (`templateId`, `dataType`, `xml`, `ownerId`) + VALUES (:templateId, :dataType, :xml, :ownerId) ', [ 'templateId' => $this->templateId, 'dataType' => $this->dataType, 'xml' => $this->xml, + 'ownerId' => $this->ownerId, ]); } @@ -315,13 +342,15 @@ private function edit(): void `templateId` = :templateId, `dataType`= :dataType, `enabled` = :enabled, - `xml` = :xml + `xml` = :xml, + `ownerId` = :ownerId WHERE `id` = :id ', [ 'templateId' => $this->templateId, 'dataType' => $this->dataType, 'xml' => $this->xml, 'enabled' => $this->isEnabled ? 1 : 0, + 'ownerId' => $this->ownerId, 'id' => $this->id, ]); } diff --git a/lib/Factory/ModuleFactory.php b/lib/Factory/ModuleFactory.php index faecaf4d7c..8cf4041aff 100644 --- a/lib/Factory/ModuleFactory.php +++ b/lib/Factory/ModuleFactory.php @@ -49,6 +49,16 @@ class ModuleFactory extends BaseFactory { use ModuleXmlTrait; + public static $systemDataTypes = [ + 'Article', + 'Event', + 'Forecast', + 'Product', + 'ProductCategory', + 'SocialMedia', + 'dataset' + ]; + /** @var Module[] all modules */ private $modules = null; @@ -433,6 +443,51 @@ public function getDataTypeById(string $dataTypeId): DataType throw new NotFoundException(__('DataType not found')); } + /** + * @return DataType[] + */ + public function getAllDataTypes() + { + $dataTypes = []; + + // get system data types + foreach (self::$systemDataTypes as $dataTypeId) { + $className = '\\Xibo\\Widget\\DataType\\' . ucfirst($dataTypeId); + if (class_exists($className)) { + $class = new $className(); + if ($class instanceof DataTypeInterface) { + $dataTypes[] = $class->getDefinition(); + } + } + + // special handling for dataset + if ($dataTypeId === 'dataset') { + $dataType = new DataType(); + $dataType->id = $dataTypeId; + $dataType->name = 'DataSet'; + $dataTypes[] = $dataType; + } + } + + // get data types from xml + $files = array_merge( + glob(PROJECT_ROOT . '/modules/datatypes/*.xml'), + glob(PROJECT_ROOT . '/custom/modules/datatypes/*.xml') + ); + + foreach ($files as $file) { + $xml = new \DOMDocument(); + $xml->load($file); + $dataType = new DataType(); + $dataType->id = $this->getFirstValueOrDefaultFromXmlNode($xml, 'id'); + $dataType->name = $this->getFirstValueOrDefaultFromXmlNode($xml, 'name'); + $dataTypes[] = $dataType; + } + + sort($dataTypes); + return $dataTypes; + } + /** * @param string $assetId * @return \Xibo\Widget\Definition\Asset diff --git a/lib/Factory/ModuleTemplateFactory.php b/lib/Factory/ModuleTemplateFactory.php index f514ffb93f..1ead7567b6 100644 --- a/lib/Factory/ModuleTemplateFactory.php +++ b/lib/Factory/ModuleTemplateFactory.php @@ -78,7 +78,7 @@ public function getByTypeAndId(string $type, string $id): ModuleTemplate */ public function getUserTemplateById(int $id): ModuleTemplate { - $templates = $this->loadUserTemplates($id); + $templates = $this->loadUserTemplates(null, ['id' => $id]); if (count($templates) !== 1) { throw new NotFoundException(sprintf(__('Template not found for %s'), $id)); } @@ -117,6 +117,22 @@ public function getByDataType(string $dataType): array return $templates; } + /** + * @param string $type + * @param string $dataType + * @return ModuleTemplate[] + */ + public function getByTypeAndDataType(string $type, string $dataType, bool $includeUserTemplates = true): array + { + $templates = []; + foreach ($this->load($includeUserTemplates) as $template) { + if ($template->dataType === $dataType && $template->type === $type) { + $templates[] = $template; + } + } + return $templates; + } + /** * @param string $assetId * @return \Xibo\Widget\Definition\Asset @@ -155,11 +171,13 @@ public function getAssetByAlias(string $alias): Asset /** * Get an array of all modules - * @return \Xibo\Entity\ModuleTemplate[] + * @param string|null $ownership + * @param bool $includeUserTemplates + * @return ModuleTemplate[] */ - public function getAll(?string $ownership = null): array + public function getAll(?string $ownership = null, bool $includeUserTemplates = true): array { - $templates = $this->load(); + $templates = $this->load($includeUserTemplates); if ($ownership === null) { return $templates; @@ -191,9 +209,10 @@ public function getAllAssets(): array /** * Load templates - * @return \Xibo\Entity\ModuleTemplate[] + * @param bool $includeUserTemplates + * @return ModuleTemplate[] */ - private function load(): array + private function load(bool $includeUserTemplates = true): array { if ($this->templates === null) { $this->getLog()->debug('load: Loading templates'); @@ -207,8 +226,14 @@ private function load(): array PROJECT_ROOT . '/custom/modules/templates/*.xml', 'custom' ), - $this->loadUserTemplates(), ); + + if ($includeUserTemplates) { + $this->templates = array_merge( + $this->templates, + $this->loadUserTemplates() + ); + } } return $this->templates; @@ -240,27 +265,93 @@ private function loadFolder(string $folder, string $ownership): array * Load user templates from the database. * @return ModuleTemplate[] */ - public function loadUserTemplates($id = null): array + public function loadUserTemplates($sortOrder = [], $filterBy = []): array { $this->getLog()->debug('load: Loading user templates'); + if (empty($sortOrder)) { + $sortOrder = ['id']; + } + $templates = []; $params = []; - $sql = 'SELECT * FROM `module_templates`'; - if ($id !== null) { - $sql .= ' WHERE `id` = :id '; - $params['id'] = $id; + $filter = $this->getSanitizer($filterBy); + + $select = 'SELECT *, + (SELECT GROUP_CONCAT(DISTINCT `group`.group) + FROM `permission` + INNER JOIN `permissionentity` + ON `permissionentity`.entityId = permission.entityId + INNER JOIN `group` + ON `group`.groupId = `permission`.groupId + WHERE entity = :permissionEntity + AND objectId = `module_templates`.id + AND view = 1 + ) AS groupsWithPermissions'; + + $body = ' FROM `module_templates` + WHERE 1 = 1 '; + + if ($filter->getInt('id') !== null) { + $body .= ' AND `id` = :id '; + $params['id'] = $filter->getInt('id'); + } + + if (!empty($filter->getString('templateId'))) { + $body .= ' AND `templateId` LIKE :templateId '; + $params['templateId'] = '%' . $filter->getString('templateId') . '%'; + } + + if (!empty($filter->getString('dataType'))) { + $body .= ' AND `dataType` = :dataType '; + $params['dataType'] = $filter->getString('dataType') ; } + $params['permissionEntity'] = 'Xibo\\Entity\\ModuleTemplate'; + + $this->viewPermissionSql( + 'Xibo\Entity\ModuleTemplate', + $body, + $params, + 'module_templates.id', + 'module_templates.ownerId', + $filterBy, + ); + + $order = ''; + if (is_array($sortOrder) && !empty($sortOrder)) { + $order .= ' ORDER BY ' . implode(',', $sortOrder); + } + + // Paging + $limit = ''; + if ($filterBy !== null && $filter->getInt('start') !== null && $filter->getInt('length') !== null) { + $limit .= ' LIMIT ' . + $filter->getInt('start', ['default' => 0]) . ', ' . + $filter->getInt('length', ['default' => 10]); + } + + $sql = $select . $body . $order. $limit; + foreach ($this->getStore()->select($sql, $params) as $row) { $template = $this->createUserTemplate($row['xml']); $template->id = intval($row['id']); $template->templateId = $row['templateId']; $template->dataType = $row['dataType']; $template->isEnabled = $row['enabled'] == 1; + $template->ownerId = intval($row['ownerId']); + $template->groupsWithPermissions = $row['groupsWithPermissions']; $templates[] = $template; } + + // Paging + if (!empty($limit) && count($templates) > 0) { + unset($params['permissionEntity']); + $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params); + $this->_countLast = intval($results[0]['total']); + } + return $templates; } @@ -397,4 +488,150 @@ private function createFromXml(\DOMElement $xml, string $ownership, string $file return $template; } + + /** + * Parse properties json into xml node. + * + * @param string $properties + * @return \DOMDocument + * @throws \DOMException + */ + public function parseJsonPropertiesToXml(string $properties): \DOMDocument + { + $newPropertiesXml = new \DOMDocument(); + $newPropertiesNode = $newPropertiesXml->createElement('properties'); + $attributes = [ + 'id', + 'type', + 'variant', + 'format', + 'mode', + 'target', + 'propertyGroupId', + 'allowLibraryRefs', + 'allowAssetRefs', + 'parseTranslations', + 'includeInXlf' + ]; + + $commonNodes = [ + 'title', + 'helpText', + 'default', + 'dependsOn', + 'customPopOver' + ]; + + $newProperties = json_decode($properties, true); + foreach ($newProperties as $property) { + // create property node + $propertyNode = $newPropertiesXml->createElement('property'); + + // go through possible attributes on the property node. + foreach ($attributes as $attribute) { + if (!empty($property[$attribute])) { + $propertyNode->setAttribute($attribute, $property[$attribute]); + } + } + + // go through common nodes on property add them if not empty + foreach ($commonNodes as $commonNode) { + if (!empty($property[$commonNode])) { + $propertyNode->appendChild($newPropertiesXml->createElement($commonNode, $property[$commonNode])); + } + } + + // do we have options? + if (!empty($property['options'])) { + $options = $property['options']; + if (!is_array($options)) { + $options = json_decode($options, true); + } + + $optionsNode = $newPropertiesXml->createElement('options'); + foreach ($options as $option) { + $optionNode = $newPropertiesXml->createElement('option', $option['title']); + $optionNode->setAttribute('name', $option['name']); + if (!empty($option['set'])) { + $optionNode->setAttribute('set', $option['set']); + } + if (!empty($option['image'])) { + $optionNode->setAttribute('image', $option['image']); + } + $optionsNode->appendChild($optionNode); + } + $propertyNode->appendChild($optionsNode); + } + + // do we have visibility? + if(!empty($property['visibility'])) { + $visibility = $property['visibility']; + if (!is_array($visibility)) { + $visibility = json_decode($visibility, true); + } + + $visibilityNode = $newPropertiesXml->createElement('visibility'); + + foreach ($visibility as $testElement) { + $testNode = $newPropertiesXml->createElement('test'); + $testNode->setAttribute('type', $testElement['type']); + $testNode->setAttribute('message', $testElement['message']); + foreach($testElement['conditions'] as $condition) { + $conditionNode = $newPropertiesXml->createElement('condition', $condition['value']); + $conditionNode->setAttribute('field', $condition['field']); + $conditionNode->setAttribute('type', $condition['type']); + $testNode->appendChild($conditionNode); + } + $visibilityNode->appendChild($testNode); + } + $propertyNode->appendChild($visibilityNode); + } + + // do we have validation rules? + if (!empty($property['rule'])) { + $validation = $property['rule']; + if (!is_array($validation)) { + $validation = json_decode($property['rule'], true); + } + + $ruleNode = $newPropertiesXml->createElement('rule'); + + foreach ($validation as $ruleTest) { + $ruleTestNode = $newPropertiesXml->createElement('test'); + $ruleTestNode->setAttribute('type', $ruleTest['type']); + $ruleTestNode->setAttribute('message', $ruleTest['message']); + + foreach($ruleTest['conditions'] as $condition) { + $conditionNode = $newPropertiesXml->createElement('condition', $condition['value']); + $conditionNode->setAttribute('field', $condition['field']); + $conditionNode->setAttribute('type', $condition['type']); + $ruleTestNode->appendChild($conditionNode); + } + $ruleNode->appendChild($ruleTestNode); + } + $propertyNode->appendChild($ruleNode); + } + + // do we have player compatibility? + if (!empty($property['playerCompatibility'])) { + $playerCompat = $property['playerCompatibility']; + if (!is_array($playerCompat)) { + $playerCompat = json_decode($property['playerCompatibility'], true); + } + + $playerCompatibilityNode = $newPropertiesXml->createElement('playerCompatibility'); + foreach ($playerCompat as $player => $value) { + $playerCompatibilityNode->setAttribute($player, $value); + } + + $propertyNode->appendChild($playerCompatibilityNode); + } + + $newPropertiesNode->appendChild($propertyNode); + } + + $newPropertiesXml->appendChild($newPropertiesNode); + + return $newPropertiesXml; + } } diff --git a/lib/Factory/UserGroupFactory.php b/lib/Factory/UserGroupFactory.php index 2ebe6caac6..1f3a0f114a 100644 --- a/lib/Factory/UserGroupFactory.php +++ b/lib/Factory/UserGroupFactory.php @@ -1,6 +1,6 @@ 'system', 'title' => __('Add/Edit custom modules and templates'), ], + 'developer.delete' => [ + 'feature' => 'developer.delete', + 'group' => 'system', + 'title' => __('Delete custom modules and templates'), + ], 'transition.view' => [ 'feature' => 'transition.view', 'group' => 'system', diff --git a/lib/Listener/ModuleTemplateListener.php b/lib/Listener/ModuleTemplateListener.php new file mode 100644 index 0000000000..46051a5bdb --- /dev/null +++ b/lib/Listener/ModuleTemplateListener.php @@ -0,0 +1,58 @@ +. + */ + +namespace Xibo\Listener; + +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Xibo\Event\ParsePermissionEntityEvent; +use Xibo\Factory\ModuleTemplateFactory; + +class ModuleTemplateListener +{ + use ListenerLoggerTrait; + + /** @var ModuleTemplateFactory */ + private ModuleTemplateFactory $moduleTemplateFactory; + + public function __construct(ModuleTemplateFactory $moduleTemplateFactory) + { + $this->moduleTemplateFactory = $moduleTemplateFactory; + } + + public function registerWithDispatcher(EventDispatcherInterface $dispatcher): ModuleTemplateListener + { + $dispatcher->addListener(ParsePermissionEntityEvent::$NAME . 'moduleTemplate', [$this, 'onParsePermissions']); + + return $this; + } + + /** + * @param ParsePermissionEntityEvent $event + * @return void + * @throws \Xibo\Support\Exception\NotFoundException + */ + public function onParsePermissions(ParsePermissionEntityEvent $event) + { + $this->getLogger()->debug('onParsePermissions'); + $event->setObject($this->moduleTemplateFactory->getUserTemplateById($event->getObjectId())); + } +} diff --git a/lib/Middleware/ListenersMiddleware.php b/lib/Middleware/ListenersMiddleware.php index b5a4c70d40..f04aa93040 100644 --- a/lib/Middleware/ListenersMiddleware.php +++ b/lib/Middleware/ListenersMiddleware.php @@ -43,6 +43,7 @@ use Xibo\Listener\LayoutListener; use Xibo\Listener\MediaListener; use Xibo\Listener\MenuBoardProviderListener; +use Xibo\Listener\ModuleTemplateListener; use Xibo\Listener\NotificationDataProviderListener; use Xibo\Listener\PlaylistListener; use Xibo\Listener\SyncGroupListener; @@ -128,6 +129,13 @@ public static function setListeners(App $app) ->useLogger($c->get('logger')) ->registerWithDispatcher($dispatcher); + // Listen for events that affect ModuleTemplates + (new ModuleTemplateListener( + $c->get('moduleTemplateFactory'), + )) + ->useLogger($c->get('logger')) + ->registerWithDispatcher($dispatcher); + // Listen for event that affect Playlist (new PlaylistListener( $c->get('playlistFactory'), diff --git a/lib/routes-web.php b/lib/routes-web.php index be35c0e5e2..3292b6f312 100644 --- a/lib/routes-web.php +++ b/lib/routes-web.php @@ -577,6 +577,8 @@ // Developer // $app->group('', function (\Slim\Routing\RouteCollectorProxy $group) { + $group->get('/developer/template/datatypes', ['\Xibo\Controller\Developer', 'getAvailableDataTypes']) + ->setName('developer.templates.datatypes.search'); $group->get('/developer/template/view', ['\Xibo\Controller\Developer', 'displayTemplatePage']) ->setName('developer.templates.view'); $group->get('/developer/template', ['\Xibo\Controller\Developer', 'templateGrid']) @@ -591,10 +593,24 @@ $group->get('/developer/template/form/edit/{id}', ['\Xibo\Controller\Developer', 'templateEditForm']) ->setName('developer.templates.form.edit'); + $group->get('/developer/template/form/delete/{id}', ['\Xibo\Controller\Developer', 'templateDeleteForm']) + ->setName('developer.templates.form.delete'); + + $group->get('/developer/template/form/copy/{id}', ['\Xibo\Controller\Developer', 'templateCopyForm']) + ->setName('developer.templates.form.copy'); + $group->post('/developer/template', ['\Xibo\Controller\Developer', 'templateAdd']) ->setName('developer.templates.add'); $group->put('/developer/template/{id}', ['\Xibo\Controller\Developer', 'templateEdit']) ->setName('developer.templates.edit'); + $group->delete('/developer/template/{id}', ['\Xibo\Controller\Developer', 'templateDelete']) + ->setName('developer.templates.delete'); + $group->get('/developer/template/{id}/export', ['\Xibo\Controller\Developer', 'templateExport']) + ->setName('developer.templates.export'); + $group->post('/developer/template/import', ['\Xibo\Controller\Developer', 'templateImport']) + ->setName('developer.templates.import'); + $group->post('/developer/template/{id}/copy', ['\Xibo\Controller\Developer', 'templateCopy']) + ->setName('developer.templates.copy'); })->addMiddleware(new FeatureAuth($app->getContainer(), ['developer.edit'])); // diff --git a/ui/src/core/file-upload.js b/ui/src/core/file-upload.js index a12b640a54..764274ca45 100644 --- a/ui/src/core/file-upload.js +++ b/ui/src/core/file-upload.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023 Xibo Signage Ltd + * Copyright (C) 2024 Xibo Signage Ltd * * Xibo - Digital Signage - https://xibosignage.com * @@ -28,6 +28,7 @@ let videoImageCovers = {}; function openUploadForm(options) { options = $.extend(true, {}, { templateId: 'template-file-upload', + uploadTemplateId: 'template-upload', videoImageCovers: true, className: '', animateDialog: true, @@ -81,6 +82,7 @@ function openUploadForm(options) { acceptFileTypes: new RegExp('\\.(' + options.templateOptions.upload.validExt + ')$', 'i'), maxFileSize: options.templateOptions.upload.maxSize, includeTagsInput: options.templateOptions.includeTagsInput, + uploadTemplateId: options.uploadTemplateId, }; let refreshSessionInterval; diff --git a/ui/src/core/xibo-cms.js b/ui/src/core/xibo-cms.js index bc4356a18d..bc91b1da04 100644 --- a/ui/src/core/xibo-cms.js +++ b/ui/src/core/xibo-cms.js @@ -3163,6 +3163,7 @@ function ToggleFilterView(div) { function makePagedSelect(element, parent) { element.select2({ dropdownParent: ((parent == null) ? $("body") : $(parent)), + minimumResultsForSearch: (element.data('hideSearch')) ? Infinity : 5, ajax: { url: element.data("searchUrl"), dataType: "json", diff --git a/views/developer-template-edit-page.twig b/views/developer-template-edit-page.twig index 01ca60cb90..f4438a8c31 100644 --- a/views/developer-template-edit-page.twig +++ b/views/developer-template-edit-page.twig @@ -97,9 +97,18 @@ {% set helpText %}{% trans "A unique ID for the module template" %}{% endset %} {{ forms.input("templateId", title, template.templateId, helpText) }} + {% set attributes = [ + { name: "data-search-url", value: url_for("developer.templates.datatypes.search") }, + { name: "data-search-term", value: "name" }, + { name: "data-id-property", value: "id" }, + { name: "data-text-property", value: "name" }, + { name: "data-initial-key", value: "dataType" }, + { name: "data-initial-value", value: template.dataType }, + { name: "data-hide-search", value: 1} + ] %} {% set title %}{% trans "Data Type" %}{% endset %} {% set helpText %}{% trans "Which data type does this template need?" %}{% endset %} - {{ forms.input("dataType", title, template.dataType, helpText) }} + {{ forms.dropdown("dataType", "single", title, null, null, "id", "id", helpText, "pagedSelect", "", "", "", attributes) }} {% set title %}{% trans "Enabled?" %}{% endset %} {% set helpText %}{% trans "Is this template enabled?" %}{% endset %} @@ -720,11 +729,15 @@ } }); } else if (controlType === 'options') { + valToSave = []; $subControlAux.find('.option-item').each((_key, option) => { var optionTitle = $(option).find('[name="control_option_title"]').val(); var optionName = $(option).find('[name="control_option_name"]').val(); if(optionTitle && optionName) { - valToSave[optionName] = optionTitle; + valToSave.push({ + title: optionTitle, + name: optionName, + }); } }); } else if(controlType === 'visibility' || controlType === 'rule') { diff --git a/views/developer-template-form-add.twig b/views/developer-template-form-add.twig index 693b600bd1..bfda4ddb28 100644 --- a/views/developer-template-form-add.twig +++ b/views/developer-template-form-add.twig @@ -33,6 +33,8 @@ {% trans "Save" %}, $("#form-module-template").submit(); {% endblock %} +{% block callBack %}moduleTemplateAddFormOpen{% endblock %} + {% block formHtml %}
@@ -45,9 +47,27 @@ {% set helpText %}{% trans "A unique ID for the module template" %}{% endset %} {{ forms.input("templateId", title, "custom_", helpText) }} + {% set attributes = [ + { name: "data-search-url", value: url_for("developer.templates.datatypes.search") }, + { name: "data-search-term", value: "name" }, + { name: "data-id-property", value: "id" }, + { name: "data-text-property", value: "name" }, + { name: "data-hide-search", value: 1}, + ] %} {% set title %}{% trans "Data Type" %}{% endset %} {% set helpText %}{% trans "Which data type does this template need?" %}{% endset %} - {{ forms.input("dataType", title, "", helpText) }} + {{ forms.dropdown("dataType", "single", title, null, null, "id", "id", helpText, "pagedSelect", "", "", "", attributes) }} + + {% set attributes = [ + { name: "data-search-term", value: "title" }, + { name: "data-id-property", value: "templateId" }, + { name: "data-text-property", value: "title" }, + { name: "data-hide-search", value: 1}, + { name: "data-filter-options", value: '{"type":"static"}' }, + ] %} + {% set title %}{% trans "Template" %}{% endset %} + {% set helpText %}{% trans "Optionally select existing template to use as a base for this Template" %}{% endset %} + {{ forms.dropdown("copyTemplateId", "single", title, null, null, "templateId", "title", helpText, "d-none", "", "", "", attributes) }}
diff --git a/views/developer-template-form-copy.twig b/views/developer-template-form-copy.twig new file mode 100644 index 0000000000..9af9fb6b42 --- /dev/null +++ b/views/developer-template-form-copy.twig @@ -0,0 +1,48 @@ +{# +/* + * Copyright (C) 2024 Xibo Signage Ltd + * + * Xibo - Digital Signage - https://xibosignage.com + * + * This file is part of Xibo. + * + * Xibo is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * any later version. + * + * Xibo is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Xibo. If not, see . + */ +#} + +{% extends "form-base.twig" %} +{% import "forms.twig" as forms %} + +{% block formTitle %} + {% set name = template.templateId %} + {% trans %}Copy {{ name }}{% endtrans %} +{% endblock %} + +{% block formButtons %} + {% trans "Cancel" %}, XiboDialogClose() + {% trans "Copy" %}, $("#form-module-template-copy").submit() +{% endblock %} + +{% block formHtml %} +
+
+
+ {% set title %}{% trans "ID" %}{% endset %} + {% set helpText %}{% trans "A unique ID for the module template" %}{% endset %} + {% set copyName %}{{ template.templateId }}_copy{% endset %} + {{ forms.input("templateId", title, copyName , helpText) }} +
+
+
+{% endblock %} \ No newline at end of file diff --git a/views/developer-template-form-delete.twig b/views/developer-template-form-delete.twig new file mode 100644 index 0000000000..0a2e4f9abe --- /dev/null +++ b/views/developer-template-form-delete.twig @@ -0,0 +1,45 @@ +{# +/** + * Copyright (C) 2024 Xibo Signage Ltd + * + * Xibo - Digital Signage - https://xibosignage.com + * + * This file is part of Xibo. + * + * Xibo is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * any later version. + * + * Xibo is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Xibo. If not, see . + */ +#} + +{% extends "form-base.twig" %} +{% import "forms.twig" as forms %} + +{% block formTitle %} + {% trans "Delete Module Template" %} +{% endblock %} + +{% block formButtons %} + {% trans "Cancel" %}, XiboDialogClose() + {% trans "Yes" %}, $("#form-module-template-delete").submit() +{% endblock %} + +{% block formHtml %} +
+
+
+ {% set message %}{% trans "Are you sure you want to delete this Template? This cannot be undone" %}{% endset %} + {{ forms.message(message) }} +
+
+
+{% endblock %} \ No newline at end of file diff --git a/views/developer-template-page.twig b/views/developer-template-page.twig index 42032bfb57..2a2b2ce58b 100644 --- a/views/developer-template-page.twig +++ b/views/developer-template-page.twig @@ -30,34 +30,55 @@ +
{% endblock %} {% block pageContent %}
-
{% trans "Modules" %}
+
{% trans "Module Templates" %}
-
+
- {% set title %}{% trans "Name" %}{% endset %} - {{ inline.input('name', title) }} + {% set title %}{% trans "ID" %}{% endset %} + {{ inline.number('id', title) }} + + {% set title %}{% trans "Title" %}{% endset %} + {{ inline.input('templateId', title) }} + + {% set attributes = [ + { name: "data-placeholder--id", value: null }, + { name: "data-placeholder--value", value: "" }, + { name: "data-width", value: "200px" }, + { name: "data-search-url", value: url_for("developer.templates.datatypes.search") }, + { name: "data-search-term", value: "name" }, + { name: "data-id-property", value: "id" }, + { name: "data-text-property", value: "name" }, + { name: "data-initial-key", value: "dataType" }, + { name: "data-allow-clear", value: "true"}, + { name: "data-hide-search", value: 1} + ] %} + {% set title %}{% trans "Data Type" %}{% endset %} + {% set helpText %}{% trans "Which data type does this template need?" %}{% endset %} + {{ inline.dropdown("dataType", "single", title, null, null, "id", "id", helpText, "pagedSelect", "", "", "", attributes) }}
- +
- - - - - - - - + + + + + + + + + @@ -71,44 +92,207 @@ {% block javaScript %} +{% endblock %} + +{% block javaScriptTemplates %} + {{ parent() }} + + {% verbatim %} + + + + + {% endverbatim %} {% endblock %} From 67263f74ad7cc7a61862d8791e486129d16ae885 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mauro=20Ferr=C3=A3o?= Date: Tue, 26 Mar 2024 10:36:28 +0000 Subject: [PATCH 027/203] Layout Editor: Zoom levels and media explorer (#2440) relates to xibosignageltd/xibo-private#653 --- ui/src/editor-core/toolbar.js | 355 +++++++++++++++--- ui/src/layout-editor/viewer.js | 4 +- ui/src/playlist-editor/playlist.js | 4 +- ui/src/style/common.scss | 23 ++ ui/src/style/playlist-editor.scss | 8 + ui/src/style/toolbar.scss | 270 +++++++++++-- ui/src/style/variables.scss | 4 + .../templates/toolbar-content-media-table.hbs | 18 + ui/src/templates/toolbar.hbs | 29 +- views/common.twig | 7 + 10 files changed, 630 insertions(+), 92 deletions(-) create mode 100644 ui/src/templates/toolbar-content-media-table.hbs diff --git a/ui/src/editor-core/toolbar.js b/ui/src/editor-core/toolbar.js index fb82455c96..5490825ec4 100644 --- a/ui/src/editor-core/toolbar.js +++ b/ui/src/editor-core/toolbar.js @@ -33,9 +33,11 @@ const ToolbarCardNewPlaylistTemplate = require('../templates/toolbar-card-playlist-new-template.hbs'); const ToolbarContentTemplate = require('../templates/toolbar-content.hbs'); const ToolbarSearchFormTemplate = -require('../templates/toolbar-search-form.hbs'); + require('../templates/toolbar-search-form.hbs'); const ToolbarContentMediaTemplate = require('../templates/toolbar-content-media.hbs'); +const ToolbarContentMediaTableTemplate = + require('../templates/toolbar-content-media-table.hbs'); const ToolbarContentSubmenuTemplate = require('../templates/toolbar-content-submenu.hbs'); const ToolbarContentSubmenuCardsTemplate = @@ -46,6 +48,9 @@ const MediaPlayerTemplate = require('../templates/toolbar-media-preview.hbs'); const MediaInfoTemplate = require('../templates/toolbar-media-preview-info.hbs'); +// Constants +const TABLE_VIEW_LEVEL = 4; + /** * Bottom toolbar contructor * @param {object} parent - parent container @@ -86,6 +91,9 @@ const Toolbar = function( // Is the toolbar a playlist toolbar? this.isPlaylist = isPlaylist; + + // Toolbar zoom level (1-4 : 2 default) + this.level = 2; }; /** @@ -122,7 +130,7 @@ Toolbar.prototype.init = function({isPlaylist = false} = {}) { if (setting.id == 'validExtensions') { el.validExtensions = - (setting.value) ? setting.value : setting.default; + (setting.value) ? setting.value : setting.default; } } @@ -135,6 +143,7 @@ Toolbar.prototype.init = function({isPlaylist = false} = {}) { moduleListOtherFiltered.push({ type: el.type, name: el.name, + hasThumbnail: el.hasThumbnail, }); // Add to types @@ -764,6 +773,9 @@ Toolbar.prototype.loadPrefs = function() { (loadedData.displayTooltips == 1 || loadedData.displayTooltips == undefined); + // Toolbar level + self.level = loadedData.level ?? 2; + // Reload tooltips app.common.reloadTooltips(self.DOMObject); @@ -811,8 +823,8 @@ Toolbar.prototype.savePrefs = _.debounce(function(clearPrefs = false) { // Make a copy of the opened submenu object let openedSubMenu = - (self.openedSubMenu != -1) ? - Object.assign({}, self.openedSubMenu) : -1; + (self.openedSubMenu != -1) ? + Object.assign({}, self.openedSubMenu) : -1; // Remove tooltip data from submenu if exists if (openedSubMenu.data && openedSubMenu.data['bs.tooltip'] != undefined) { @@ -820,6 +832,7 @@ Toolbar.prototype.savePrefs = _.debounce(function(clearPrefs = false) { } let displayTooltips = (app.common.displayTooltips) ? 1 : 0; + let level = self.level; let favouriteModules = []; const filters = {}; const sort = {}; @@ -837,6 +850,7 @@ Toolbar.prototype.savePrefs = _.debounce(function(clearPrefs = false) { openedMenu = -1; openedSubMenu = -1; displayTooltips = 1; + level = 2; } else { // Save favourite modules const widgetMenu = self.menuItems.find(function(el) { @@ -875,6 +889,7 @@ Toolbar.prototype.savePrefs = _.debounce(function(clearPrefs = false) { openedSubMenu: openedSubMenu, displayTooltips: displayTooltips, favouriteModules: favouriteModules, + level: level, }), }, ], @@ -941,7 +956,7 @@ Toolbar.prototype.render = function({savePrefs = true} = {}) { const self = this; const app = this.parent; const readOnlyModeOn = - (typeof(lD) != 'undefined' && lD?.readOnlyMode === true) || + (typeof (lD) != 'undefined' && lD?.readOnlyMode === true) || (app?.readOnlyMode === true); // Deselect selected card on render @@ -961,9 +976,10 @@ Toolbar.prototype.render = function({savePrefs = true} = {}) { trans: newToolbarTrans, mainObjectType: app.mainObjectType, helpLink: ( - (typeof(layoutEditorHelpLink) != 'undefined') ? + (typeof (layoutEditorHelpLink) != 'undefined') ? layoutEditorHelpLink : null ), + toolbarLevel: this.level, }); // Clear temp data @@ -1036,6 +1052,35 @@ Toolbar.prototype.render = function({savePrefs = true} = {}) { this.openMenu(this.openedMenu, true, openedSubMenu, savePrefs); } } + + // Zoom level control + const $toolbarLevelControlMenuBtn = + this.DOMObject.find('.toolbar-level-control-button'); + + $toolbarLevelControlMenuBtn + .on('click', (ev) => { + $(ev.currentTarget).parent().toggleClass('opened'); + }); + + this.DOMObject.find('.toolbar-level-control-select') + .on('click', (ev) => { + const newLevel = $(ev.target).data('level'); + + // Close menu + $(ev.target).parents('.toolbar-level-control-menu') + .removeClass('opened'); + + // Change toolbar level if changed + if (self.level != newLevel) { + self.level = newLevel; + + // Render toolbar + self.render(); + + // Refresh viewer + (app.viewer) && app.viewer.update(); + } + }); }; /** @@ -1397,7 +1442,7 @@ Toolbar.prototype.handleDroppables = function(draggable, customClasses = '') { // new dragabble of type not global selectorBuild.push( '.designer-element-group[data-element-type="' + - elementDataType + '"].editing, ' + + elementDataType + '"].editing, ' + '.designer-element-group[data-element-type="global"].editing', ); } else { @@ -1491,10 +1536,17 @@ Toolbar.prototype.deselectCardsAndDropZones = function() { Toolbar.prototype.mediaContentCreateWindow = function(menu) { const self = this; const app = this.parent; + const hasProvider = Boolean(this.menuItems[menu].provider); // Deselect previous selections self.deselectCardsAndDropZones(); + // Table view + const tableView = ( + self.level === TABLE_VIEW_LEVEL && + !hasProvider + ); + // Render template const html = ToolbarContentMediaTemplate({ menuIndex: menu, @@ -1504,8 +1556,10 @@ Toolbar.prototype.mediaContentCreateWindow = function(menu) { sortDir: this.menuItems[menu].sortDir, trans: toolbarTrans, formClass: 'media-search-form', - // Hide sort for providers - showSort: !this.menuItems[menu].provider, + // Hide sort for providers and table view + showSort: + !hasProvider && + !tableView, }); // Clear temp data @@ -1514,8 +1568,10 @@ Toolbar.prototype.mediaContentCreateWindow = function(menu) { // Append template to the search main div self.DOMObject.find('#media-container-' + menu).html(html); - // Populate selected tab - self.mediaContentPopulate(menu); + // Populate selected tab with table or grid + (tableView) ? + self.mediaContentPopulateTable(menu) : + self.mediaContentPopulate(menu); }; /** @@ -1526,12 +1582,11 @@ Toolbar.prototype.mediaContentPopulate = function(menu) { const self = this; const app = this.parent; const $mediaContainer = self.DOMObject.find('#media-container-' + menu); + const $mediaContent = self.DOMObject.find('#media-content-' + menu); + const $mediaForm = $mediaContainer.find('.media-search-form'); // Request elements based on filters const loadData = function(clear = true) { - const $mediaContent = self.DOMObject.find('#media-content-' + menu); - const $mediaForm = $mediaContent.parent().find('.media-search-form'); - // Remove show more button $mediaContainer.find('.show-more').remove(); @@ -1613,8 +1668,7 @@ Toolbar.prototype.mediaContentPopulate = function(menu) { $mediaContent.masonry({ itemSelector: '.toolbar-card', columnWidth: '.toolbar-card-sizer', - percentPosition: true, - gutter: 8, + gutter: 6, }).addClass('masonry-container'); // Show upload card @@ -1666,6 +1720,7 @@ Toolbar.prototype.mediaContentPopulate = function(menu) { toolbarTrans.noMediaToShow + ''); } else { + let isVideo = false; for (let index = 0; index < res.data.length; index++) { const element = Object.assign({}, res.data[index]); element.trans = toolbarTrans; @@ -1683,6 +1738,7 @@ Toolbar.prototype.mediaContentPopulate = function(menu) { // Get video thumbnail for videos with provider // Local videos will have an image thumbnail if (element.type == 'video' && element.provider) { + isVideo = true; element.videoThumbnail = element.thumbnail; } @@ -1743,6 +1799,14 @@ Toolbar.prototype.mediaContentPopulate = function(menu) { // Handle card behaviour self.handleCardsBehaviour(); }); + + // For video, wait for 3s and call + // masonry layout to fix layout after loading + if (isVideo) { + setTimeout(() => { + $mediaContent.masonry('layout'); + }, 3000); + } } }).catch(function(jqXHR, textStatus, errorThrown) { // Login Form needed? @@ -1869,50 +1933,250 @@ Toolbar.prototype.mediaContentPopulate = function(menu) { self.menuItems[menu].sortDir, ); + // Handle inputs + self.mediaContentHandleInputs($mediaForm, filterRefresh, $mediaContainer); + + // Load data + loadData(); +}; + +/** + * Media content populate table + * @param {number} menu - menu id + */ +Toolbar.prototype.mediaContentPopulateTable = function(menu) { + const self = this; + const app = this.parent; + const $mediaContainer = self.DOMObject.find('#media-container-' + menu); + const $mediaContent = $mediaContainer.find('#media-content-' + menu); + const typeColNum = 2; + const thumbColNum = 4; + + // Destroy previous table if exists + $mediaContent.find('#media-table-' + menu).DataTable().destroy(); + $mediaContent.empty(); + + // Add table and header + $mediaContent.html(ToolbarContentMediaTableTemplate({ + menu: menu, + trans: toolbarTrans.mediaTable, + })); + + // Media form + const $mediaForm = $mediaContainer.find('.media-search-form'); + + const mediaTable = $mediaContent.find('#media-table-' + menu).DataTable({ + language: dataTablesLanguage, + lengthMenu: [5, 10, 15, 25], + pageLength: 10, + autoWidth: false, + serverSide: true, + stateSave: true, + searchDelay: 3000, + order: [[1, 'asc']], + filter: false, + ajax: { + url: urlsForApi.library.get.url + '?assignable=1&retired=0', + data: function(d) { + const filter = $mediaForm.serializeObject(); + + // If we have type not defined, set types to other types + if ( + self.menuItems[menu].name == 'library' && + filter.type == '' + ) { + filter.types = self.moduleListOtherFiltered.map((el) => el.type); + } + + $.extend( + d, + filter, + ); + }, + }, + columns: [ + { + data: 'mediaId', + className: 'dt-col id-col', + }, + { + data: 'name', + className: 'dt-col name-col', + render: function(data) { + return '
' + + data + '
'; + }, + }, + { + data: 'mediaType', + className: 'dt-col type-col', + }, + { + sortable: false, + data: dataTableCreateTags, + className: 'dt-col tags-col', + }, + { + sortable: false, + data: null, + className: 'dt-col thumb-col', + render: function(data, type, row, _meta) { + if (type === 'display') { + // Return only the image part of the data + if (!data.thumbnail) { + return ''; + } else { + return ` + `; + } + } else { + return row.mediaId; + } + }, + }, + { + sortable: false, + className: 'dt-col action-col', + data: function(_data, type, _row, _meta) { + if (type !== 'display') { + return ''; + } + + // Create a click-able span + return `
+ + + +
`; + }, + }, + ], + createdRow: function(row, data) { + // Add toolbar-card class to row + $(row).addClass('toolbar-card'); + + // Add data + $(row).data({ + dataType: '', + mediaId: data.mediaId, + subType: data.mediaType, + target: 'layout playlist drawer zone', + type: 'media', + }); + }, + }); + + // Check if we have type and thumbnail column visible based on type + mediaTable.column(typeColNum).visible( + $mediaContainer.find('form').serializeObject().type === '', + ); + mediaTable.column(thumbColNum).visible( + ['video', 'image'].includes( + $mediaContainer.find('form').serializeObject().type, + ), + ); + + mediaTable.on('draw', function(e, settings) { + dataTableDraw(e, settings); + + // Clicky on the +spans + self.DOMObject.find('.assignItem').click(function(ev) { + const $target = $(ev.currentTarget); + // Get the row that this is in. + const data = mediaTable.row($target.closest('tr')).data(); + + // Add with click if playlist + if (self.isPlaylist) { + console.log('Add with click!'); + app.dropItemAdd({}, $target.closest('tr')); + } else { + self.selectCard($target.closest('tr'), data); + } + }); + }); + + mediaTable.on('processing.dt', dataTableProcessing); + + // Refresh the table results + const filterRefresh = function() { + const filters = self.menuItems[menu].filters; + // Save filter options + for (const filter in filters) { + if (filters.hasOwnProperty(filter)) { + filters[filter].value = + $mediaForm.find('#input-' + filter).val(); + } + } + + // Check if we show type and thumbnail + mediaTable.column(typeColNum).visible(filters.type.value === ''); + mediaTable.column(thumbColNum).visible( + ['video', 'image'].includes(filters.type.value), + ); + + // Reload table + mediaTable.ajax.reload(); + + self.savePrefs(); + }; + + // Handle inputs + self.mediaContentHandleInputs($mediaForm, filterRefresh, $mediaContainer); +}; + +/** + * Handle inputs for media form + * @param {object} $mediaForm - form container + * @param {function} filterRefresh - Filter refresh callback + * @param {object} $mediaContainer - media container + */ +Toolbar.prototype.mediaContentHandleInputs = function( + $mediaForm, + filterRefresh, + $mediaContainer, +) { // Prevent filter form submit and bind the change event to reload the table - $mediaContainer.find('.media-search-form').on('submit', function(e) { + $mediaForm.on('submit', function(e) { e.preventDefault(); return false; }); // Bind search action to refresh the results - $mediaContainer.find( - '.media-search-form select, ' + - '.media-search-form input[type="text"].input-tag', + $mediaForm.find( + 'select, ' + + 'input[type="text"].input-tag', ).change(_.debounce(function() { filterRefresh(); }, 200)); // Bind tags change to refresh the results - $mediaContainer.find('.media-search-form input[type="text"]') + $mediaForm.find('input[type="text"]') .on('input', _.debounce(function() { filterRefresh(); }, 500)); // Initialize tagsinput - const $tags = $mediaContainer - .find('.media-search-form input[data-role="tagsinput"]'); + const $tags = $mediaForm + .find('input[data-role="tagsinput"]'); $tags.tagsinput(); - $mediaContainer.find('#media-' + menu).off('click') + $mediaContainer.off('click') .on('click', '#tagDiv .btn-tag', function(e) { // Add text to form $tags.tagsinput('add', $(e.target).text(), {allowDuplicates: false}); }); // Initialize user list input - const $userListInput = $mediaContainer - .find('.media-search-form select[name="ownerId"]'); + const $userListInput = $mediaForm.find('select[name="ownerId"]'); makePagedSelect($userListInput); // Initialize other select inputs - $mediaContainer - .find('.media-search-form select:not([name="ownerId"]):not(.input-sort)') + $mediaForm + .find('select:not([name="ownerId"]):not(.input-sort)') .select2({ minimumResultsForSearch: -1, // Hide search box }); - - // Load data - loadData(); }; /** @@ -2049,8 +2313,7 @@ Toolbar.prototype.layoutTemplatesContentPopulate = function(menu) { $content.masonry({ itemSelector: '.toolbar-card', columnWidth: '.toolbar-card-sizer', - percentPosition: true, - gutter: 8, + gutter: 6, }).addClass('masonry-container'); // Manage request length @@ -2523,25 +2786,27 @@ Toolbar.prototype.handleCardsBehaviour = function() { const app = this.parent; const self = this; const readOnlyModeOn = - (typeof(lD) != 'undefined' && lD?.readOnlyMode === true) || + (typeof (lD) != 'undefined' && lD?.readOnlyMode === true) || (app?.readOnlyMode === true); // If in edit mode if (!readOnlyModeOn) { - this.DOMObject.find('.toolbar-card:not(.toolbar-card-menu)') + this.DOMObject.find('.toolbar-card:not(.toolbar-card-menu):not(tr)') .each(function(idx, el) { const $card = $(el); $card.draggable({ appendTo: $card.parents('.toolbar-pane:first'), cursorAt: { top: - ( - $card.height() + ($card.outerWidth(true) - $card.outerWidth()) / 2 - ) / 2, + ( + $card.height() + + ($card.outerWidth(true) - $card.outerWidth()) / 2 + ) / 2, left: - ( - $card.width() + ($card.outerWidth(true) - $card.outerWidth()) / 2 - ) / 2, + ( + $card.width() + + ($card.outerWidth(true) - $card.outerWidth()) / 2 + ) / 2, }, opacity: 0.7, helper: function(ev) { @@ -2554,7 +2819,7 @@ Toolbar.prototype.handleCardsBehaviour = function() { return $clone; }, start: function() { - // Deselect previous selections + // Deselect previous selections self.deselectCardsAndDropZones(); // Show overlay @@ -2568,7 +2833,7 @@ Toolbar.prototype.handleCardsBehaviour = function() { $card.parents('nav.navbar').addClass('card-selected'); }, stop: function() { - // Hide overlay + // Hide overlay $('.custom-overlay').hide(); // Remove card class as being dragged @@ -2583,7 +2848,7 @@ Toolbar.prototype.handleCardsBehaviour = function() { // Select normal card this.DOMObject.find( - '.toolbar-card:not(.toolbar-card-menu):not(.card-selected)', + '.toolbar-card:not(.toolbar-card-menu):not(.card-selected):not(tr)', ).click((e) => { self.selectCard($(e.currentTarget)); }); @@ -2614,7 +2879,7 @@ Toolbar.prototype.handleCardsBehaviour = function() { // Toggle favourite card this.DOMObject.find( - '.toolbar-card:not(.card-selected) '+ + '.toolbar-card:not(.card-selected) ' + '.btn-favourite', ).click((e) => { self.toggleFavourite(e.currentTarget); diff --git a/ui/src/layout-editor/viewer.js b/ui/src/layout-editor/viewer.js index ba5f8c34dd..01c3372dd3 100644 --- a/ui/src/layout-editor/viewer.js +++ b/ui/src/layout-editor/viewer.js @@ -1063,7 +1063,9 @@ Viewer.prototype.update = function() { this.updateMoveable(true); // Update moveable options - this.updateMoveableOptions(); + this.updateMoveableOptions({ + savePreferences: false, + }); }; /** diff --git a/ui/src/playlist-editor/playlist.js b/ui/src/playlist-editor/playlist.js index f54720235b..da8888ab15 100644 --- a/ui/src/playlist-editor/playlist.js +++ b/ui/src/playlist-editor/playlist.js @@ -147,12 +147,12 @@ Playlist.prototype.calculateTimeValues = function() { /** * Add action to take after dropping a draggable item - * @param {object} droppable - Target drop object + * @param {object} _droppable - Target drop object * @param {object} draggable - Dragged object * @param {number=} addToPosition - Add to specific position in the widget list */ Playlist.prototype.addObject = function( - droppable, + _droppable, draggable, addToPosition = null, ) { diff --git a/ui/src/style/common.scss b/ui/src/style/common.scss index 585c5a0587..b73bc16634 100644 --- a/ui/src/style/common.scss +++ b/ui/src/style/common.scss @@ -259,3 +259,26 @@ $fa-font-path: "~font-awesome/fonts"; @extend .fa, .fa-exclamation-triangle; padding: 0 5px; } + +/* Toolbar level icons */ +.toolbar-level-icon { + background-size: 20px 20px; + background-repeat: no-repeat; + background-position: center; +} + +.toolbar-level-control-1 { + background-image: url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='utf-8'%3F%3E%3C!-- Generator: Adobe Illustrator 27.8.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --%3E%3Csvg version='1.1' id='Layer_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' viewBox='0 0 20 20' style='enable-background:new 0 0 20 20;' xml:space='preserve'%3E%3Cg%3E%3Cpath fill='%23fff' d='M3.3,20H0.8C0.4,20,0,19.6,0,19.2V0.8C0,0.4,0.4,0,0.8,0h2.5c0.4,0,0.8,0.4,0.8,0.8v18.4C4.1,19.6,3.7,20,3.3,20z'/%3E%3C/g%3E%3Cg%3E%3Cpath fill='%23fff8' d='M8.6,20H6.1c-0.4,0-0.8-0.4-0.8-0.8V0.8C5.3,0.4,5.7,0,6.1,0h2.5c0.4,0,0.8,0.4,0.8,0.8v18.4C9.4,19.6,9.1,20,8.6,20z'/%3E%3C/g%3E%3Cg%3E%3Cpath fill='%23fff8' d='M13.9,20h-2.5c-0.4,0-0.8-0.4-0.8-0.8V0.8c0-0.4,0.4-0.8,0.8-0.8h2.5c0.4,0,0.8,0.4,0.8,0.8v18.4 C14.7,19.6,14.3,20,13.9,20z'/%3E%3C/g%3E%3Cg%3E%3Cpath fill='%23fff8' d='M19.2,20h-2.5c-0.4,0-0.8-0.4-0.8-0.8V0.8c0-0.4,0.4-0.8,0.8-0.8h2.5C19.6,0,20,0.4,20,0.8v18.4C20,19.6,19.6,20,19.2,20z' /%3E%3C/g%3E%3C/svg%3E%0A"); +} + +.toolbar-level-control-2 { + background-image: url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='utf-8'%3F%3E%3C!-- Generator: Adobe Illustrator 27.8.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --%3E%3Csvg version='1.1' id='Layer_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' viewBox='0 0 20 20' style='enable-background:new 0 0 20 20;' xml:space='preserve'%3E%3Cg%3E%3Cpath fill='%23fff' d='M3.3,20H0.8C0.4,20,0,19.6,0,19.2V0.8C0,0.4,0.4,0,0.8,0h2.5c0.4,0,0.8,0.4,0.8,0.8v18.4C4.1,19.6,3.7,20,3.3,20z'/%3E%3C/g%3E%3Cg%3E%3Cpath fill='%23fff' d='M8.6,20H6.1c-0.4,0-0.8-0.4-0.8-0.8V0.8C5.3,0.4,5.7,0,6.1,0h2.5c0.4,0,0.8,0.4,0.8,0.8v18.4C9.4,19.6,9.1,20,8.6,20z'/%3E%3C/g%3E%3Cg%3E%3Cpath fill='%23fff8' d='M13.9,20h-2.5c-0.4,0-0.8-0.4-0.8-0.8V0.8c0-0.4,0.4-0.8,0.8-0.8h2.5c0.4,0,0.8,0.4,0.8,0.8v18.4 C14.7,19.6,14.3,20,13.9,20z'/%3E%3C/g%3E%3Cg%3E%3Cpath fill='%23fff8' d='M19.2,20h-2.5c-0.4,0-0.8-0.4-0.8-0.8V0.8c0-0.4,0.4-0.8,0.8-0.8h2.5C19.6,0,20,0.4,20,0.8v18.4C20,19.6,19.6,20,19.2,20z' /%3E%3C/g%3E%3C/svg%3E%0A"); +} + +.toolbar-level-control-3 { + background-image: url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='utf-8'%3F%3E%3C!-- Generator: Adobe Illustrator 27.8.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --%3E%3Csvg version='1.1' id='Layer_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' viewBox='0 0 20 20' style='enable-background:new 0 0 20 20;' xml:space='preserve'%3E%3Cg%3E%3Cpath fill='%23fff' d='M3.3,20H0.8C0.4,20,0,19.6,0,19.2V0.8C0,0.4,0.4,0,0.8,0h2.5c0.4,0,0.8,0.4,0.8,0.8v18.4C4.1,19.6,3.7,20,3.3,20z'/%3E%3C/g%3E%3Cg%3E%3Cpath fill='%23fff' d='M8.6,20H6.1c-0.4,0-0.8-0.4-0.8-0.8V0.8C5.3,0.4,5.7,0,6.1,0h2.5c0.4,0,0.8,0.4,0.8,0.8v18.4C9.4,19.6,9.1,20,8.6,20z'/%3E%3C/g%3E%3Cg%3E%3Cpath fill='%23fff' d='M13.9,20h-2.5c-0.4,0-0.8-0.4-0.8-0.8V0.8c0-0.4,0.4-0.8,0.8-0.8h2.5c0.4,0,0.8,0.4,0.8,0.8v18.4 C14.7,19.6,14.3,20,13.9,20z'/%3E%3C/g%3E%3Cg%3E%3Cpath fill='%23fff8' d='M19.2,20h-2.5c-0.4,0-0.8-0.4-0.8-0.8V0.8c0-0.4,0.4-0.8,0.8-0.8h2.5C19.6,0,20,0.4,20,0.8v18.4C20,19.6,19.6,20,19.2,20z' /%3E%3C/g%3E%3C/svg%3E%0A"); +} + +.toolbar-level-control-4 { + background-image: url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='utf-8'%3F%3E%3C!-- Generator: Adobe Illustrator 27.8.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --%3E%3Csvg version='1.1' id='Layer_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' viewBox='0 0 20 20' style='enable-background:new 0 0 20 20;' xml:space='preserve'%3E%3Cg%3E%3Cpath fill='%23fff' d='M3.3,20H0.8C0.4,20,0,19.6,0,19.2V0.8C0,0.4,0.4,0,0.8,0h2.5c0.4,0,0.8,0.4,0.8,0.8v18.4C4.1,19.6,3.7,20,3.3,20z'/%3E%3C/g%3E%3Cg%3E%3Cpath fill='%23fff' d='M8.6,20H6.1c-0.4,0-0.8-0.4-0.8-0.8V0.8C5.3,0.4,5.7,0,6.1,0h2.5c0.4,0,0.8,0.4,0.8,0.8v18.4C9.4,19.6,9.1,20,8.6,20z'/%3E%3C/g%3E%3Cg%3E%3Cpath fill='%23fff' d='M13.9,20h-2.5c-0.4,0-0.8-0.4-0.8-0.8V0.8c0-0.4,0.4-0.8,0.8-0.8h2.5c0.4,0,0.8,0.4,0.8,0.8v18.4 C14.7,19.6,14.3,20,13.9,20z'/%3E%3C/g%3E%3Cg%3E%3Cpath fill='%23fff' d='M19.2,20h-2.5c-0.4,0-0.8-0.4-0.8-0.8V0.8c0-0.4,0.4-0.8,0.8-0.8h2.5C19.6,0,20,0.4,20,0.8v18.4C20,19.6,19.6,20,19.2,20z' /%3E%3C/g%3E%3C/svg%3E%0A"); +} diff --git a/ui/src/style/playlist-editor.scss b/ui/src/style/playlist-editor.scss index 76fc18889b..005ff89040 100644 --- a/ui/src/style/playlist-editor.scss +++ b/ui/src/style/playlist-editor.scss @@ -211,6 +211,14 @@ $timeline-step-height: 22px; .playlist-editor-inline-container { padding: 16px; + + #playlist-timeline { + position: relative; + + .loading-container { + height: calc(100vh - 140px); + } + } } #playlist-editor { diff --git a/ui/src/style/toolbar.scss b/ui/src/style/toolbar.scss index 82457ecfd4..da40a439cf 100644 --- a/ui/src/style/toolbar.scss +++ b/ui/src/style/toolbar.scss @@ -44,20 +44,6 @@ .navbar-btn { margin: 8px 3px; } - - .editor-help-link { - width: 60px; - height: 30px; - display: flex; - align-items: center; - justify-content: center; - font-size: 1.2rem; - text-decoration: none; - - &:hover { - color: $xibo-color-neutral-0; - } - } } .toolbar-pane { @@ -104,7 +90,6 @@ bottom: 0; background: $xibo-color-primary-l5; color: $xibo-color-primary; - width: 288px; .content-header { display: flex; @@ -187,19 +172,107 @@ text-align: center; } - .toolbar-pane-content { + .toolbar-pane-content:not(.masonry-container) { display: flex; flex-direction: row; width: auto; white-space: nowrap; flex-wrap: wrap; - justify-content: space-between; + gap: 6px; .toolbar-header { flex-basis: 100%; - margin: 6px 0 4px 0; + margin: 12px 0 4px 0; text-transform: uppercase; } + + .dataTables_wrapper { + margin: 0 -15px; + + .toolbar-card { + cursor: inherit; + height: 50px; + + &.card-selected { + background-color: $xibo-color-primary-l10; + + .assign-item-container { + display: none; + } + } + } + + .table { + table-layout: fixed; + } + + .dt-col { + padding: 6px; + } + + .id-col { + width: 10% !important; + } + + .name-col { + min-width: 30%; + max-width: 45%; + + & > div { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + .type-col { + width: 15% !important; + } + + .tags-col { + width: 20%; + + #tagDiv { + display: flex; + flex-wrap: wrap; + } + } + + .thumb-col { + width: 20%; + + img { + max-height: 50px; + max-width: 100%; + } + + &:hover { + max-height: 100px; + } + } + + .action-col { + width: 5%; + + .assign-item-container { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + + .assignItem { + font-size: 1.2rem; + background-color: $xibo-color-primary; + padding: 6px; + border-radius: 6px; + + &:hover { + background-color: darken($xibo-color-primary, 5%); + } + } + } + } + } } } @@ -262,7 +335,6 @@ padding: 0 !important; flex-direction: column; background: none; - height: 120px !important; grid-gap: 0 !important; .toolbar-content { @@ -279,7 +351,7 @@ background: $xibo-color-primary-l60; border-radius: 4px; padding: 4px 1px; - height: 80px; + max-height: 80px; width: 100%; img { @@ -310,8 +382,8 @@ top: 0; left: 0; @include border-radius(4px); - width: 15px; - height: 15px; + width: 18px; + height: 18px; opacity: 0.8; transition-delay: 0s; @@ -392,9 +464,9 @@ } &.toolbar-library-pane { - .toolbar-pane-content { + .toolbar-pane-content.masonry-container { align-content: flex-start; - overflow-y: auto; + overflow-y: scroll; } .toolbar-pane-container { @@ -402,15 +474,9 @@ flex-direction: column; } - // Card sizer for masonry layout - .toolbar-card-sizer { - width: calc(50% - 4px); - } - .toolbar-card { cursor: grab; background: none; - margin-bottom: 8px; height: auto; border: none; @@ -461,6 +527,7 @@ font-size: 0; overflow: hidden; border: 2px solid $xibo-color-primary-l30; + min-height: 60px; } .media-title { @@ -622,12 +689,10 @@ opacity: 0.9; line-height: 20px; } - - min-height: $toolbar-widgets-card-height; - height: $toolbar-widgets-card-height; background: $xibo-color-primary-l20; color: $xibo-color-primary-d60; border: 1px solid $xibo-color-primary-l30; + min-height: $toolbar-widgets-card-height; padding: 4px; margin-bottom: 2px; } @@ -658,8 +723,6 @@ .toolbar-card { cursor: pointer; min-height: $toolbar-widgets-card-height; - height: $toolbar-widgets-card-height; - margin-bottom: $toolbar-widgets-card-margin-bottom; display: flex; gap: 8px; padding: 8px; @@ -686,10 +749,10 @@ position: absolute; z-index: calc(#{$toolbar-card-z-index} + 1); background: $xibo-color-primary; - width: 16px; + width: 18px; @include border-radius(0px 0 4px 0); margin: 0; - height: 16px; + height: 18px; text-align: center; font-size: 0.85rem; top: 0; @@ -935,6 +998,72 @@ } } } + + .toolbar-controls { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + + .toolbar-level-control, .toolbar-help-control { + display: block; + cursor: pointer; + color: $xibo-color-primary-l5; + width: calc($toolbar-width - 20px); + text-align: center; + font-size: 1.2rem; + padding: 10px; + line-height: 20px; + height: calc($toolbar-tab-menu-height - 10px); + border-radius: 8px; + @include set-transparent-color(background-color, $xibo-color-primary-d60, 0.4); + + &.btn-menu-option-custom-icon { + padding: 8px; + } + + .btn-menu-custom-icon { + width: 100%; + height: calc($toolbar-tab-menu-height - 16px); + margin: 0; + } + + &:hover { + @include set-transparent-color(background-color, $xibo-color-primary-d60, 0.6); + color: $xibo-color-neutral-100; + } + } + + .toolbar-level-control-menu { + position: relative; + + .toolbar-level-control-select { + position: absolute; + left: 54px; + bottom: -4px; + background-color: $xibo-color-primary-d60; + flex-direction: column; + padding: 4px; + border-radius: 6px; + gap: 4px; + display: none; + + .toolbar-level-control:hover { + background-color: lighten($xibo-color-primary-d60, 10); + } + } + + &.opened { + .toolbar-level-control-select { + display: flex; + } + + .toolbar-level-control-button { + @include set-transparent-color(background-color, $xibo-color-primary-d60, 0.8); + } + } + } + } } /* Side bar */ @@ -943,7 +1072,72 @@ padding: 1rem 0 !important; &.opened { - margin-right: 290px; + &.toolbar-level-1 { + margin-right: $toolbar-content-level-1-width; + + .toolbar-menu-content .toolbar-pane { + width: $toolbar-content-level-1-width; + + .toolbar-card, .toolbar-card-sizer { + width: 100% !important; + } + + + /* Filter form */ + form.media-search-form, form.layout_tempates-search-form { + .form-group { + width: 100%; + } + } + } + } + + &.toolbar-level-2 { + margin-right: $toolbar-content-level-2-width; + + .toolbar-menu-content .toolbar-pane { + width: $toolbar-content-level-2-width; + + .toolbar-card, .toolbar-card-sizer { + width: calc(calc(100%/2) - 3px) !important; + } + + + } + } + + &.toolbar-level-3 { + margin-right: $toolbar-content-level-3-width; + + .toolbar-menu-content .toolbar-pane { + width: $toolbar-content-level-3-width; + + .toolbar-card { + width: calc(calc(100%/3) - 4px) !important; + } + + // Card sizer for masonry layout ( 2 columns ) + .toolbar-card-sizer, .masonry-container .toolbar-card { + width: calc(calc(100%/2) - 3px) !important; + } + } + } + + &.toolbar-level-4 { + margin-right: $toolbar-content-level-4-width; + + .toolbar-menu-content .toolbar-pane { + width: $toolbar-content-level-4-width; + + .toolbar-card { + width: calc(calc(100%/4) - 4.5px) !important; + } + + .toolbar-card-sizer, .masonry-container .toolbar-card { + width: calc(calc(100%/3) - 4px) !important; + } + } + } } } diff --git a/ui/src/style/variables.scss b/ui/src/style/variables.scss index a600264311..87fb780624 100644 --- a/ui/src/style/variables.scss +++ b/ui/src/style/variables.scss @@ -80,6 +80,10 @@ $toolbar-width: 60px; $toolbar-tab-menu-height: 50px; $toolbar-header-height: 50px; $toolbar-content-header-height: 40px; +$toolbar-content-level-1-width: 160px; +$toolbar-content-level-2-width: 290px; +$toolbar-content-level-3-width: 420px; +$toolbar-content-level-4-width: 600px; $toolbar-widgets-card-height: 60px; $toolbar-widgets-card-margin-bottom: 0.5rem; diff --git a/ui/src/templates/toolbar-content-media-table.hbs b/ui/src/templates/toolbar-content-media-table.hbs new file mode 100644 index 0000000000..0ec1bc36d0 --- /dev/null +++ b/ui/src/templates/toolbar-content-media-table.hbs @@ -0,0 +1,18 @@ +
+
+
{% trans "ID" %}{% trans "Template ID" %}{% trans "Data Type" %}{% trans "Title" %}{% trans "Type" %}
{% trans "ID" %}{% trans "Template ID" %}{% trans "Data Type" %}{% trans "Title" %}{% trans "Type" %}{% trans "Sharing" %}
+ + + + + + + + + + + + +
{{trans.mediaId}}{{trans.mediaName}}{{trans.mediaType}}{{trans.mediaTags}}{{trans.mediaThumb}}
+
+
\ No newline at end of file diff --git a/ui/src/templates/toolbar.hbs b/ui/src/templates/toolbar.hbs index 0e62751751..3e8537faf3 100644 --- a/ui/src/templates/toolbar.hbs +++ b/ui/src/templates/toolbar.hbs @@ -1,4 +1,4 @@ -
diff --git a/views/developer-template-page.twig b/views/developer-template-page.twig index 2a2b2ce58b..72f795f993 100644 --- a/views/developer-template-page.twig +++ b/views/developer-template-page.twig @@ -114,8 +114,8 @@ { data: 'id' , responsivePriority: 2}, { data: 'templateId' , responsivePriority: 2}, { data: 'dataType' }, - { data: 'title' }, - { data: 'type' }, + { data: 'title', orderable: false }, + { data: 'type', orderable: false }, { data: 'groupsWithPermissions', responsivePriority: 3, From c28e47330a7d5b6524ef6b9ceb81336a8c182f50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mauro=20Ferr=C3=A3o?= Date: Wed, 27 Mar 2024 09:18:04 +0000 Subject: [PATCH 031/203] Template Editor controls fixes (#2456) relates to xibosignageltd/xibo-private#644 - Fix for deleting advanced options and not being saved - Fix for saving advanced controls without twice JSON stringify --- views/developer-template-edit-page.twig | 293 ++++++++++++++++++------ 1 file changed, 220 insertions(+), 73 deletions(-) diff --git a/views/developer-template-edit-page.twig b/views/developer-template-edit-page.twig index 829e12fd60..6980be063f 100644 --- a/views/developer-template-edit-page.twig +++ b/views/developer-template-edit-page.twig @@ -266,7 +266,7 @@ - +{% endblock %} \ No newline at end of file diff --git a/reports/displayalerts-report-preview.twig b/reports/displayalerts-report-preview.twig new file mode 100644 index 0000000000..9a3289725e --- /dev/null +++ b/reports/displayalerts-report-preview.twig @@ -0,0 +1,122 @@ +{# +/** + * Copyright (C) 2020 Xibo Signage Ltd + * + * Xibo - Digital Signage - http://www.xibo.org.uk + * + * This file is part of Xibo. + * + * Xibo is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * any later version. + * + * Xibo is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Xibo. If not, see . + */ +#} + +{% extends "authed.twig" %} +{% import "inline.twig" as inline %} + +{% block actionMenu %} +
+ +
+{% endblock %} + +{% block pageContent %} + +
+
+ + {{ metadata.title }} + ({% trans "Generated on: " %}{{ metadata.generatedOn }}) +
{% trans "From" %} {{ metadata.periodStart }} {% trans "To" %} {{ metadata.periodEnd }}
+
+
+ +
+
+ +
+ + + + + + + + + + + + + + + +
{% trans "Display ID" %}{% trans "Display" %}{% trans "Event Type" %}{% trans "Start" %}{% trans "End" %}{% trans "Reference" %}{% trans "Detail" %}
+
+
+
+
+ +{% endblock %} + +{% block javaScript %} + +{% endblock %} \ No newline at end of file diff --git a/reports/displayalerts-schedule-form-add.twig b/reports/displayalerts-schedule-form-add.twig new file mode 100644 index 0000000000..5a60f409a8 --- /dev/null +++ b/reports/displayalerts-schedule-form-add.twig @@ -0,0 +1,106 @@ +{# +/** + * Copyright (C) 2024 Xibo Signage Ltd + * + * Xibo - Digital Signage - https://xibosignage.com + * + * This file is part of Xibo. + * + * Xibo is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * any later version. + * + * Xibo is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Xibo. If not, see . + */ +#} +{% extends "form-base.twig" %} +{% import "forms.twig" as forms %} + +{% block formTitle %} + {% trans "Add Report Schedule" %} +{% endblock %} + +{% block formButtons %} + {% trans "Cancel" %}, XiboDialogClose() + {% trans "Save" %}, $("#reportScheduleAddForm").submit() +{% endblock %} + +{% block callBack %}displayAlertsReportScheduleFormOpen{% endblock %} + +{% block formHtml %} +
+
+
+ {{ forms.hidden("hiddenFields", hiddenFields) }} + {{ forms.hidden("reportName", reportName) }} + + {% set title %}{% trans "Name" %}{% endset %} + {% set helpText %}{% trans "The name for this report schedule" %}{% endset %} + {{ forms.input("name", title, "", helpText, "", "required") }} + + {% set title %}{% trans "Frequency" %}{% endset %} + {% set helpText %}{% trans "Select how frequently you would like this report to run" %}{% endset %} + {% set daily %}{% trans "Daily" %}{% endset %} + {% set weekly %}{% trans "Weekly" %}{% endset %} + {% set monthly %}{% trans "Monthly" %}{% endset %} + {% set yearly %}{% trans "Yearly" %}{% endset %} + {% set options = [ + { name: "daily", filter: daily }, + { name: "weekly", filter: weekly }, + { name: "monthly", filter: monthly }, + { name: "yearly", filter: yearly }, + ] %} + {{ forms.dropdown("filter", "single", title, "", options, "name", "filter", helpText) }} + + {% set title %}{% trans "Display" %}{% endset %} + {% set attributes = [ + { name: "data-width", value: "100%" }, + { name: "data-allow-clear", value: "true" }, + { name: "data-placeholder--id", value: null }, + { name: "data-placeholder--value", value: "" }, + { name: "data-search-url", value: url_for("display.search") }, + { name: "data-search-term", value: "display" }, + { name: "data-search-term-tags", value: "tags" }, + { name: "data-id-property", value: "displayId" }, + { name: "data-text-property", value: "display" } + ] %} + {{ forms.dropdown("displayId", "single", title, "", null, "displayId", "display", "", "pagedSelect", "", "d", "", attributes) }} + + {% set title %}{% trans "Display Group" %}{% endset %} + {% set attributes = [ + { name: "data-width", value: "100%" }, + { name: "data-allow-clear", value: "true" }, + { name: "data-placeholder--id", value: null }, + { name: "data-placeholder--value", value: "" }, + { name: "data-search-url", value: url_for("displayGroup.search") }, + { name: "data-search-term", value: "displayGroup" }, + { name: "data-id-property", value: "displayGroupId" }, + { name: "data-text-property", value: "displayGroup" } + ] %} + {{ forms.dropdown("displayGroupId[]", "dropdownmulti", title, "", null, "displayGroupId", "displayGroup", "", "pagedSelect", "", "d", "", attributes) }} + + {% set title %}{% trans "Start Time" %}{% endset %} + {% set helpText %}{% trans "Set a future date and time to run this report. Leave blank to run from the next collection point." %}{% endset %} + {{ forms.dateTime("fromDt", title, "", helpText, "starttime-control") }} + + {% set title %}{% trans "End Time" %}{% endset %} + {% set helpText %}{% trans "Set a future date and time to end the schedule. Leave blank to run indefinitely." %}{% endset %} + {{ forms.dateTime("toDt", title, "", helpText, "endtime-control") }} + + {% set title %}{% trans "Should an email be sent?" %}{% endset %} + {{ forms.checkbox("sendEmail", title, sendEmail) }} + + {% set title %}{% trans "Email addresses" %}{% endset %} + {% set helpText %}{% trans "Additional emails separated by a comma." %}{% endset %} + {{ forms.inputWithTags("nonusers", title, nonusers, helpText) }} +
+
+
+{% endblock %} \ No newline at end of file diff --git a/reports/displayalerts.report b/reports/displayalerts.report new file mode 100644 index 0000000000..d5616ccd4f --- /dev/null +++ b/reports/displayalerts.report @@ -0,0 +1,14 @@ +{ + "name": "displayalerts", + "description": "Display Alerts", + "class": "\\Xibo\\Report\\DisplayAlerts", + "type": "Report", + "output_type": "table", + "color":"orange", + "fa_icon": "fa-bell", + "sort_order": 5, + "hidden": 0, + "category": "Display", + "feature": "displays.reporting", + "adminOnly": 0 +} \ No newline at end of file diff --git a/tests/Xmds/RegisterDisplayTest.php b/tests/Xmds/RegisterDisplayTest.php index 6757684dcd..d39bc1ed8e 100644 --- a/tests/Xmds/RegisterDisplayTest.php +++ b/tests/Xmds/RegisterDisplayTest.php @@ -1,6 +1,6 @@ loadXML($result); $this->assertSame('READY', $innerDocument->documentElement->getAttribute('code')); - $this->assertSame('Display is active and ready to start.', $innerDocument->documentElement->getAttribute('message')); + $this->assertSame( + 'Display is active and ready to start.', + $innerDocument->documentElement->getAttribute('message') + ); } public function testRegisterDisplayNoAuth() @@ -99,4 +102,33 @@ public function testRegisterDisplayNoAuth() } } } + + public function testRegisterNewDisplay() + { + $request = $this->sendRequest( + 'POST', + $this->register( + 'PHPUnitAddedTest' . mt_rand(1, 10), + 'phpunitaddedtest', + 'android' + ), + 7 + ); + + $response = $request->getBody()->getContents(); + + $document = new DOMDocument(); + $document->loadXML($response); + + $xpath = new DOMXpath($document); + $result = $xpath->evaluate('string(//ActivationMessage)'); + $innerDocument = new DOMDocument(); + $innerDocument->loadXML($result); + + $this->assertSame('ADDED', $innerDocument->documentElement->getAttribute('code')); + $this->assertSame( + 'Display is now Registered and awaiting Authorisation from an Administrator in the CMS', + $innerDocument->documentElement->getAttribute('message') + ); + } } diff --git a/tests/Xmds/Xibo/Tests/Xmds/SubmitLogTest.php b/tests/Xmds/Xibo/Tests/Xmds/SubmitLogTest.php new file mode 100644 index 0000000000..dc0543bcb8 --- /dev/null +++ b/tests/Xmds/Xibo/Tests/Xmds/SubmitLogTest.php @@ -0,0 +1,56 @@ +. + */ + +namespace Xibo\Tests\Xmds; + +use GuzzleHttp\Exception\GuzzleException; +use Xibo\Tests\xmdsTestCase; + +class SubmitLogTest extends XmdsTestCase +{ + use XmdsHelperTrait; + + public function setUp(): void + { + parent::setUp(); + } + + /** + * Submit log with category event + * @return void + * @throws GuzzleException + */ + public function testSubmitEventLog() + { + $request = $this->sendRequest( + 'POST', + $this->submitEventLog('7'), + 7 + ); + + $this->assertStringContainsString( + 'true', + $request->getBody()->getContents(), + 'Submit Log received incorrect response' + ); + } +} diff --git a/tests/Xmds/XmdsHelperTrait.php b/tests/Xmds/XmdsHelperTrait.php index 41ef1a0b38..b1113256da 100644 --- a/tests/Xmds/XmdsHelperTrait.php +++ b/tests/Xmds/XmdsHelperTrait.php @@ -1,6 +1,6 @@ 6v4RduQhaw5Q PHPUnit'.$version.' - [{date:"2023-04-20 17:03:52",expires:"2023-04-21 17:03:52",code:00001,reason:"Test",scheduleId:0,layoutId:0,regionId:0,mediaId:0,widgetId:0}] + [{"date":"2023-04-20 17:03:52","expires":"2023-04-21 17:03:52","code":"10001","reason":"Test","scheduleId":"0","layoutId":0,"regionId":"0","mediaId":"0","widgetId":"0"}] '; @@ -116,4 +116,21 @@ public function getWidgetData($version, $widgetId) '; } + + public function submitEventLog($version): string + { + return ' + + + 6v4RduQhaw5Q + PHPUnit'. $version .' + <log><event date="2024-04-10 12:45:55" category="event"><eventType>App Start</eventType><message>Detailed message about this event</message><alertType>both</alertType><refId></refId></event></log> + + + '; + } } diff --git a/views/command-form-add.twig b/views/command-form-add.twig index d0807db010..c52996bb73 100644 --- a/views/command-form-add.twig +++ b/views/command-form-add.twig @@ -72,6 +72,17 @@ {% set helpText %}{% trans "Leave empty if this command should be available on all types of Display." %}{% endset %} {{ forms.dropdown("availableOn[]", "dropdownmulti", title, "", options, "optionid", "option", helpText, "selectPicker") }} + + {% set options = [ + { optionid: "never", option: "Never" }, + { optionid: "success", option: "Success" }, + { optionid: "failure", option: "Failure" }, + { optionid: "always", option: "Always" }, + ] %} + {% set title %}{% trans "Create Alert On" %}{% endset %} + {% set helpText %}{% trans "On command execution, when should a Display alert be created?" %}{% endset %} + + {{ forms.dropdown("createAlertOn", "single", title, "never", options, "optionid", "option", helpText) }}
diff --git a/views/command-form-edit.twig b/views/command-form-edit.twig index dcf0efe7d9..3d1fd87067 100644 --- a/views/command-form-edit.twig +++ b/views/command-form-edit.twig @@ -73,6 +73,17 @@ {% set helpText %}{% trans "Leave empty if this command should be available on all types of Display." %}{% endset %} {{ forms.dropdown("availableOn[]", "dropdownmulti", title, command.getAvailableOn(), options, "optionid", "option", helpText, "selectPicker") }} + + {% set options = [ + { optionid: "never", option: "Never" }, + { optionid: "success", option: "Success" }, + { optionid: "failure", option: "Failure" }, + { optionid: "always", option: "Always" }, + ] %} + {% set title %}{% trans "Create Alert On" %}{% endset %} + {% set helpText %}{% trans "On command execution, when should a Display alert be created?" %}{% endset %} + + {{ forms.dropdown("createAlertOn", "single", title, command.createAlertOn, options, "optionid", "option", helpText) }}
diff --git a/views/displayprofile-form-edit-command-fields.twig b/views/displayprofile-form-edit-command-fields.twig index 8339772ffc..f04b13bad3 100644 --- a/views/displayprofile-form-edit-command-fields.twig +++ b/views/displayprofile-form-edit-command-fields.twig @@ -56,6 +56,23 @@ {% set title %}{% trans "Validation" %}{% endset %} {% set helpText %}{% trans "The Validation String for this Command on this display" %}{% endset %} {{ forms.input(fieldId, title, field.validationStringDisplayProfile, helpText) }} + + {% if field.createAlertOn != "" %} + {{ forms.disabled("", "", "This Command has a default setting for creating alerts."|trans, field.createAlertOn) }} + {% endif %} + + {% set fieldId = "createAlertOn_" ~ field.commandId %} + {% set options = [ + { optionid: "", option: "" }, + { optionid: "never", option: "Never" }, + { optionid: "success", option: "Success" }, + { optionid: "failure", option: "Failure" }, + { optionid: "always", option: "Always" }, + ] %} + {% set title %}{% trans "Create Alert On" %}{% endset %} + {% set helpText %}{% trans "On command execution, when should a Display alert be created?" %}{% endset %} + + {{ forms.dropdown(fieldId, "single", title, field.createAlertOnDisplayProfile, options, "optionid", "option", helpText) }}
diff --git a/web/xmds.php b/web/xmds.php index f3b9a75d83..541d551081 100755 --- a/web/xmds.php +++ b/web/xmds.php @@ -1,6 +1,6 @@ get('playerVersionFactory'), $container->get('dispatcher'), $container->get('campaignFactory'), - $container->get('syncGroupFactory') + $container->get('syncGroupFactory'), + $container->get('playerFaultFactory') ); // Add manual raw post data parsing, as HTTP_RAW_POST_DATA is deprecated. From 26a44a320788ef682ed6f320d5ad7752d7cea735 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mauro=20Ferr=C3=A3o?= Date: Thu, 18 Apr 2024 17:01:26 +0100 Subject: [PATCH 044/203] Bugfix/giacobini toolbar tabs height fixes (#2487) * Playlist Editor: Toolbar Width control off screen relates to xibosignageltd/xibo-private#679 * Editor toolbar: Add scroll for smaller resolutions relates to xibosignageltd/xibo-private#681 --- ui/src/editor-core/toolbar.js | 4 ++++ ui/src/style/playlist-editor.scss | 35 +++++++++++++++++++++++++++++-- ui/src/style/toolbar.scss | 9 +++++++- 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/ui/src/editor-core/toolbar.js b/ui/src/editor-core/toolbar.js index ac92467eca..a472bd8974 100644 --- a/ui/src/editor-core/toolbar.js +++ b/ui/src/editor-core/toolbar.js @@ -1348,6 +1348,10 @@ Toolbar.prototype.openMenu = function( } } + // Add toolbar level to the playlist editor modal + this.DOMObject.parents('.editor-modal') + .attr('toolbar-level', this.level); + // if menu was closed, save preferences and clean content if (!this.opened) { // Save user preferences diff --git a/ui/src/style/playlist-editor.scss b/ui/src/style/playlist-editor.scss index 005ff89040..8e2c4c86fd 100644 --- a/ui/src/style/playlist-editor.scss +++ b/ui/src/style/playlist-editor.scss @@ -69,10 +69,9 @@ $timeline-step-height: 22px; } &.toolbar-opened { - padding-left: 350px; + .back-button { - width: 348px; a { min-width: 100px; width: auto; @@ -81,6 +80,38 @@ $timeline-step-height: 22px; display: inline; } } + + &[toolbar-level="1"] { + padding-left: 220px; + + .back-button { + width: 220px; + } + } + + &[toolbar-level="2"] { + padding-left: 350px; + + .back-button { + width: 350px; + } + } + + &[toolbar-level="3"] { + padding-left: 480px; + + .back-button { + width: 480px; + } + } + + &[toolbar-level="4"] { + padding-left: 660px; + + .back-button { + width: 660px; + } + } } &.source-editor-opened { diff --git a/ui/src/style/toolbar.scss b/ui/src/style/toolbar.scss index ffcfb51fe0..8e7db8baf4 100644 --- a/ui/src/style/toolbar.scss +++ b/ui/src/style/toolbar.scss @@ -850,6 +850,11 @@ cursor: pointer; } + .navbar-buttons { + width: $toolbar-width; + overflow: auto; + } + .navbar-buttons a.active { background: $xibo-color-primary-l5; color: $xibo-color-primary; @@ -880,7 +885,7 @@ .btn-menu-option { display: block; color: $xibo-color-primary-l5; - width: $toolbar-width; + width: 100%; text-align: center; font-size: 1.5rem; padding: 10px; @@ -995,6 +1000,7 @@ flex-direction: column; align-items: center; gap: 6px; + padding-top: 14px; .toolbar-level-control, .toolbar-help-control { display: block; @@ -1138,6 +1144,7 @@ top: 50px; left: 0; bottom: 0; + height: calc(100% - 50px); .navbar-submenu > a.option-container { text-align: center; From 87b23d86b29d5874e62bb03d25c6b6d2e51a6b83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mauro=20Ferr=C3=A3o?= Date: Fri, 19 Apr 2024 09:30:35 +0100 Subject: [PATCH 045/203] Editor Bottom Bar: Add template info for static widgets (#2489) relates to xibosignageltd/xibo-private#682 --- lib/Controller/Layout.php | 1 + ui/src/editor-core/bottombar.js | 28 ++++++++++++++++----------- ui/src/editor-core/widget.js | 1 + ui/src/style/bottombar.scss | 1 - ui/src/templates/bottombar-viewer.hbs | 2 +- views/layout-designer-page.twig | 2 ++ 6 files changed, 22 insertions(+), 13 deletions(-) diff --git a/lib/Controller/Layout.php b/lib/Controller/Layout.php index bd74409493..0de076312e 100644 --- a/lib/Controller/Layout.php +++ b/lib/Controller/Layout.php @@ -1492,6 +1492,7 @@ public function grid(Request $request, Response $response) } $widget->setUnmatchedProperty('moduleName', $module->name); + $widget->setUnmatchedProperty('moduleDataType', $module->dataType); if ($module->regionSpecific == 0) { // Use the media assigned to this widget diff --git a/ui/src/editor-core/bottombar.js b/ui/src/editor-core/bottombar.js index c3ff239edc..f112043405 100644 --- a/ui/src/editor-core/bottombar.js +++ b/ui/src/editor-core/bottombar.js @@ -46,17 +46,23 @@ Bottombar.prototype.render = function(object) { ''; if (object.type == 'widget') { - // Render widget toolbar - this.DOMObject.html(bottomBarViewerTemplate( - { - trans: newBottomBarTrans, - readOnlyModeOn: readOnlyModeOn, - object: object, - objectTypeName: newBottomBarTrans.objectType.widget, - undoActive: checkHistory.undoActive, - trashActive: trashBinActive, - }, - )); + lD.templateManager.getTemplateById( + object.getOptions().templateId, + object.moduleDataType, + ).then((template) => { + // Render widget toolbar + this.DOMObject.html(bottomBarViewerTemplate( + { + trans: newBottomBarTrans, + readOnlyModeOn: readOnlyModeOn, + object: object, + objectTypeName: newBottomBarTrans.objectType.widget, + moduleTemplateTitle: template.title, + undoActive: checkHistory.undoActive, + trashActive: trashBinActive, + }, + )); + }); } else if (object.type == 'layout') { // Render layout toolbar this.DOMObject.html(bottomBarViewerTemplate( diff --git a/ui/src/editor-core/widget.js b/ui/src/editor-core/widget.js index 8c8d75dce1..692ff7df15 100644 --- a/ui/src/editor-core/widget.js +++ b/ui/src/editor-core/widget.js @@ -75,6 +75,7 @@ const Widget = function(id, data, regionId = null, layoutObject = null) { this.type = 'widget'; this.subType = data.type; this.moduleName = data.moduleName; + this.moduleDataType = data.moduleDataType; // Permissions this.isEditable = data.isEditable; diff --git a/ui/src/style/bottombar.scss b/ui/src/style/bottombar.scss index eb12acf758..b4b151a09d 100644 --- a/ui/src/style/bottombar.scss +++ b/ui/src/style/bottombar.scss @@ -98,7 +98,6 @@ } .label-name { - line-height: 1; display: flex; gap: 8px; diff --git a/ui/src/templates/bottombar-viewer.hbs b/ui/src/templates/bottombar-viewer.hbs index 2fd0e42378..32959d6994 100644 --- a/ui/src/templates/bottombar-viewer.hbs +++ b/ui/src/templates/bottombar-viewer.hbs @@ -5,7 +5,7 @@
{{#ifCond object.widgetName '||' object.moduleName}}
- {{objectTypeName}}
{{#if object.widgetName}}"{{object.widgetName}}"{{/if}}
+ {{objectTypeName}}
{{#if object.widgetName}}"{{object.widgetName}}"{{/if}}{{#if moduleTemplateTitle}} | "{{moduleTemplateTitle}}"{{/if}}
{{/ifCond}}
diff --git a/views/layout-designer-page.twig b/views/layout-designer-page.twig index b82992dd43..4ac2c62790 100644 --- a/views/layout-designer-page.twig +++ b/views/layout-designer-page.twig @@ -137,6 +137,8 @@ nextWidget: "{% trans "Next widget" %}", previousWidget: "{% trans "Previous widget" %}", widgetName: "{% trans "Widget Name" %}", + widgetType: "{% trans "Widget Type" %}", + widgetTemplate: "{% trans "Widget Template Name" %}", elementName: "{% trans "Element Name" %}", elementGroupName: "{% trans "Element Group Name" %}", regionName: "{% trans "Region Name" %}", From b4524997eee7f5aed3fd8de3b056dbde84793e1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mauro=20Ferr=C3=A3o?= Date: Fri, 19 Apr 2024 13:55:18 +0100 Subject: [PATCH 046/203] Image Element improvements (#2490) relates to xibosignageltd/xibo-private#668 - Replace image element source control - Shadow added to images - Layout background droppable z-index fix --- modules/templates/global-elements.xml | 54 ++++++++++++++++++- ui/bundle_templates.js | 2 + ui/src/editor-core/element.js | 19 +++++++ ui/src/editor-core/properties-panel.js | 54 +++++++++++++++++++ ui/src/editor-core/toolbar.js | 21 ++++++-- ui/src/style/forms.scss | 24 +++++++++ ui/src/style/layout-editor.scss | 9 +++- .../templates/forms/inputs/imageReplace.hbs | 11 ++++ 8 files changed, 187 insertions(+), 7 deletions(-) create mode 100644 ui/src/templates/forms/inputs/imageReplace.hbs diff --git a/modules/templates/global-elements.xml b/modules/templates/global-elements.xml index ec33f68d36..1b89b73fb6 100644 --- a/modules/templates/global-elements.xml +++ b/modules/templates/global-elements.xml @@ -666,17 +666,61 @@ if (useCurrentDate) { + + Image Shadow + 0 + Should the image have a shadow? + + + Image Shadow Colour + + + 1 + + + + + Shadow X Offset + 1 + + + 1 + + + + + Shadow Y Offset + 1 + + + 1 + + + + + Shadow Blur + 2 + + + 1 + + + +
{{#if url}} {{/if}} @@ -700,6 +744,12 @@ $(target).find('img').xiboImageRender(properties); true 250 250 + + + Replace Image + Add an image from the Toolbar to replace this element. + + + + diff --git a/ui/src/editor-core/element.js b/ui/src/editor-core/element.js index ba874df03d..20f3a8a56c 100644 --- a/ui/src/editor-core/element.js +++ b/ui/src/editor-core/element.js @@ -43,6 +43,9 @@ const Element = function(data, widgetId, regionId, parentWidget) { this.isDeletable = (parentWidget) ? parentWidget.isDeletable : true; this.isViewable = (parentWidget) ? parentWidget.isViewable : true; + // Check if the element is visible on rendering ( true by default ) + this.isVisible = (data.isVisible === undefined) ? true : data.isVisible; + // Element data from the linked widget/module this.data = {}; diff --git a/ui/src/editor-core/properties-panel.js b/ui/src/editor-core/properties-panel.js index 7682183a33..f8c095e7e1 100644 --- a/ui/src/editor-core/properties-panel.js +++ b/ui/src/editor-core/properties-panel.js @@ -1009,7 +1009,7 @@ PropertiesPanel.prototype.render = function( self.DOMObject.find('#appearanceTab'), targetAux.elementId, null, - null, + targetAux.template.propertyGroups, 'element-property', ); diff --git a/ui/src/editor-core/toolbar.js b/ui/src/editor-core/toolbar.js index e929846909..2b755432d6 100644 --- a/ui/src/editor-core/toolbar.js +++ b/ui/src/editor-core/toolbar.js @@ -25,6 +25,8 @@ const ToolbarTemplate = require('../templates/toolbar.hbs'); const ToolbarCardMediaTemplate = require('../templates/toolbar-card-media.hbs'); const ToolbarCardMediaUploadTemplate = require('../templates/toolbar-card-media-upload.hbs'); +const ToolbarCardMediaPlaceholderTemplate = + require('../templates/toolbar-card-media-placeholder.hbs'); const ToolbarCardLayoutTemplateTemplate = require('../templates/toolbar-card-layout-template.hbs'); const ToolbarCardPlaylistTemplate = @@ -1495,6 +1497,16 @@ Toolbar.prototype.handleDroppables = function(draggable, customClasses = '') { ['.designer-region-drawer[data-action-edit-type="create"]']; } + // Image placeholder + if ( + $(draggable).data('type') === 'media' && + $(draggable).data('subType') === 'image' + ) { + selectorBuild.push( + '.designer-element[data-sub-type="image_placeholder"]', + ); + } + // Add droppable class to all selectors selectorBuild = selectorBuild.map((selector) => { return selector + selectorAppend; @@ -1733,6 +1745,31 @@ Toolbar.prototype.mediaContentPopulate = function(menu) { } } + // Add placeholder for images if we're in template editing mode + if ( + app.templateEditMode && + self.menuItems[menu].contentType === 'media' && + self.menuItems[menu].name === 'image' + ) { + const $placeholderCard = $(ToolbarCardMediaPlaceholderTemplate( + { + template: 'image_placeholder', + title: toolbarTrans.placeholder.image.title, + description: toolbarTrans.placeholder.image.description, + startWidth: 300, + startHeight: 150, + extends: { + template: 'global_image', + override: 'url', + with: '', + }, + }), + ); + + $mediaContent.append($placeholderCard) + .masonry('appended', $placeholderCard); + } + if ( (!res.data || res.data.length == 0) && $mediaContent.find('.toolbar-card').length == 0 @@ -3336,6 +3373,7 @@ Toolbar.prototype.loadTemplates = function( if ( el.showIn == 'playlist' && !self.isPlaylist || el.showIn == 'layout' && self.isPlaylist || + el.showIn == 'templateEditor' && !app.templateEditMode || el.showIn == 'none' || el.type === 'element' && self.isPlaylist || el.type === 'element-group' && self.isPlaylist diff --git a/ui/src/editor-core/widget.js b/ui/src/editor-core/widget.js index 692ff7df15..e299298619 100644 --- a/ui/src/editor-core/widget.js +++ b/ui/src/editor-core/widget.js @@ -483,6 +483,7 @@ const Widget = function(id, data, regionId = null, layoutObject = null) { layer: element.layer, rotation: element.rotation, properties: element.properties, + isVisible: element.isVisible, }; // If we have group, add group properties diff --git a/ui/src/layout-editor/main.js b/ui/src/layout-editor/main.js index 724ee5ee5d..b45d96e975 100644 --- a/ui/src/layout-editor/main.js +++ b/ui/src/layout-editor/main.js @@ -438,6 +438,17 @@ lD.selectObject = lD.common.hasTarget(card, 'element') ); + const dropToImagePlaceholder = ( + target && + ( + target.is('.designer-element[data-sub-type="image_placeholder"]') + ) && + ( + $(card).data('type') === 'media' && + $(card).data('subType') === 'image' + ) + ); + // Deselect cards and drop zones this.toolbar.deselectCardsAndDropZones(); @@ -448,7 +459,8 @@ lD.selectObject = dropToDrawerOrZone || dropToWidget || dropToActionTarget || - dropToElementAndElGroup + dropToElementAndElGroup || + dropToImagePlaceholder ) ) { // Send click position if we're adding to elements and element groups @@ -1493,6 +1505,8 @@ lD.dropItemAdd = function(droppable, draggable, dropPosition) { const droppableIsPlaylist = ($(droppable).data('subType') === 'playlist'); const droppableIsWidget = $(droppable).hasClass('designer-widget'); let droppableIsElement = $(droppable).hasClass('designer-element'); + const droppableIsImagePlaceholder = + $(droppable).is('.designer-element[data-sub-type="image_placeholder"]'); const droppableIsElementGroup = $(droppable).hasClass('designer-element-group'); let getTemplateBeforeAdding = ''; @@ -1670,6 +1684,15 @@ lD.dropItemAdd = function(droppable, draggable, dropPosition) { let addToGroupId = null; let addToGroupWidgetId = null; let addToExistingElementId = null; + let placeholderElement = {}; + + if (droppableIsImagePlaceholder) { + placeholderElement = { + id: $(droppable).attr('id'), + widgetId: $(droppable).data('widgetId'), + regionId: $(droppable).data('regionId'), + }; + } // If target is type global ( and being edited ) // if draggable is type global or if both are the same type @@ -1723,6 +1746,7 @@ lD.dropItemAdd = function(droppable, draggable, dropPosition) { properties, groupProperties, mediaId, + isVisible, } = {}, ) { // Create element object @@ -1738,6 +1762,7 @@ lD.dropItemAdd = function(droppable, draggable, dropPosition) { layer: layer, rotation: rotation, mediaId: mediaId, + isVisible: isVisible, }; // Add group id if it belongs to a group @@ -1861,6 +1886,7 @@ lD.dropItemAdd = function(droppable, draggable, dropPosition) { left: (dropPosition) ? dropPosition.left : 0, }, mediaId: draggableData.mediaId, + isVisible: draggableData.isVisible, }); // Add element to elements array @@ -1890,6 +1916,7 @@ lD.dropItemAdd = function(droppable, draggable, dropPosition) { extendsOverride: draggableData.extendsOverride, extendsOverrideId: draggableData.extendsOverrideId, mediaId: draggableData.mediaId, + isVisible: draggableData.isVisible, }; let addToGroup = false; @@ -1979,6 +2006,62 @@ lD.dropItemAdd = function(droppable, draggable, dropPosition) { addToGroup = true; } + // If we have a placeholder, change new element + // dimensions to match it + if (!$.isEmptyObject(placeholderElement)) { + const widgetId = + 'widget_' + + placeholderElement.regionId + + '_' + + placeholderElement.widgetId; + + const placeholder = lD.getObjectByTypeAndId( + 'element', + placeholderElement.id, + widgetId, + ); + + const widget = lD.getObjectByTypeAndId( + 'widget', + widgetId, + 'canvas', + ); + + // Position options + elementOptions.top = placeholder.top; + elementOptions.left = placeholder.left; + elementOptions.width = placeholder.width; + elementOptions.height = placeholder.height; + elementOptions.layer = placeholder.layer; + + // Properties + elementOptions.properties = placeholder.properties; + + // If placeholder was in a group, add to same + if ( + placeholder.groupId != '' && + placeholder.groupId != undefined + ) { + // Set group type as the same as the target widget + addToGroupType = widget.subType; + + // Add group object + elementOptions.groupId = placeholder.groupId; + + addToGroup = true; + } + + // Remove placeholder + widget.removeElement( + placeholderElement.id, + { + save: (elementOptions.type != 'global'), + reloadLayerManager: false, + reload: false, + }, + ); + } + // Create element const element = createElement(elementOptions); diff --git a/ui/src/layout-editor/viewer.js b/ui/src/layout-editor/viewer.js index 33969c4e73..b0438fc3d7 100644 --- a/ui/src/layout-editor/viewer.js +++ b/ui/src/layout-editor/viewer.js @@ -394,6 +394,7 @@ Viewer.prototype.render = function(forceReload = false, target = {}) { this.parent.selectedObject.groupId, ), this.parent.selectedObject.elementId, + true, ); } @@ -587,6 +588,26 @@ Viewer.prototype.handleInteractions = function() { }); }); + // Handle droppable - image placeholder + this.DOMObject.find( + '.designer-element[data-sub-type="image_placeholder"]', + ).each((_idx, element) => { + const $el = $(element); + + $el.droppable({ + greedy: true, + tolerance: 'pointer', + accept: (draggable) => { + return ( + $(draggable).data('type') === 'media' && + $(draggable).data('subType') === 'image' + ); + }, + drop: _.debounce(function(event, ui) { + lD.dropItemAdd(event.target, ui.draggable[0]); + }, 200), + }); + }); // Handle click and double click let clicks = 0; @@ -1059,6 +1080,22 @@ Viewer.prototype.update = function() { ); }.bind(this)); + // If we are selecting an element in a group, + // we need to put the group in edit mode + if ( + self.parent.selectedObject.type == 'element' && + self.parent.selectedObject.groupId != undefined + ) { + self.editGroup( + self.DOMObject.find( + '.designer-element-group#' + + self.parent.selectedObject.groupId, + ), + self.parent.selectedObject.elementId, + true, + ); + } + // Update moveable this.updateMoveable(true); @@ -3745,6 +3782,7 @@ Viewer.prototype.saveTemporaryObject = function(objectId, objectType, data) { Viewer.prototype.editGroup = function( groupDOMObject, elementToSelectOnLoad = null, + forceEditOn = false, ) { const self = this; const editing = $(groupDOMObject).hasClass('editing'); @@ -3764,7 +3802,7 @@ Viewer.prototype.editGroup = function( } // If we're not editing yet, start - if (!editing) { + if (!editing || forceEditOn) { // Get group object from structure const groupId = $(groupDOMObject).attr('id'); const groupObj = lD.getObjectByTypeAndId( diff --git a/ui/src/style/layout-editor.scss b/ui/src/style/layout-editor.scss index 04531e83f6..b1132d035d 100644 --- a/ui/src/style/layout-editor.scss +++ b/ui/src/style/layout-editor.scss @@ -1145,6 +1145,7 @@ body.editor-opened { &.ui-droppable-active { @include z-index-set($designer-select-z-index); background: $xibo-color-accent !important; + outline: 3px solid $xibo-color-accent; &.ui-droppable-hover, &:hover { @@ -1264,6 +1265,7 @@ body.editor-opened { &.ui-droppable-active { @include z-index-set($designer-select-z-index); background: $xibo-color-accent !important; + outline: 3px solid $xibo-color-accent; .group-select-overlay { display: initial; @@ -1312,6 +1314,7 @@ body.editor-opened { .designer-region.ui-droppable-active { @include z-index-set($designer-select-z-index); background: $xibo-color-region-hover !important; + outline: 3px solid $xibo-color-region-hover; &.ui-droppable-hover, &:hover { diff --git a/ui/src/style/toolbar.scss b/ui/src/style/toolbar.scss index 04f91159ed..c9065db7fb 100644 --- a/ui/src/style/toolbar.scss +++ b/ui/src/style/toolbar.scss @@ -506,7 +506,7 @@ 100% { background-position: 0% 100%; } } - &.no-thumbnail, &.upload-card, &.new-playlist-card { + &.no-thumbnail, &.upload-card, &.placeholder-card, &.new-playlist-card { min-height: 85px; .toolbar-card-preview { @@ -517,7 +517,7 @@ } } - &.upload-card, &.new-playlist-card { + &.upload-card, &.placeholder-card, &.new-playlist-card { .toolbar-card-preview { background: $xibo-color-primary-d60; } diff --git a/ui/src/templates/toolbar-card-item.hbs b/ui/src/templates/toolbar-card-item.hbs index ce3354e698..46ba53443d 100644 --- a/ui/src/templates/toolbar-card-item.hbs +++ b/ui/src/templates/toolbar-card-item.hbs @@ -3,6 +3,7 @@ data-type="{{type}}" data-sub-type="{{#if subType}}{{subType}}{{else}}{{dataType}}{{/if}}" data-data-type="{{dataType}}" + data-is-visible="{{isVisible}}" data-target="{{#if target}}{{target}}{{else}}layout element{{/if}}" data-template-id="{{templateId}}" data-template-start-width="{{startWidth}}" diff --git a/ui/src/templates/toolbar-card-media-placeholder.hbs b/ui/src/templates/toolbar-card-media-placeholder.hbs new file mode 100644 index 0000000000..a1a1f8cb51 --- /dev/null +++ b/ui/src/templates/toolbar-card-media-placeholder.hbs @@ -0,0 +1,22 @@ +
+ +
+ +
+ {{title}} +
\ No newline at end of file diff --git a/views/campaign-preview.twig b/views/campaign-preview.twig index e43e0d26fc..1c3a7ea937 100644 --- a/views/campaign-preview.twig +++ b/views/campaign-preview.twig @@ -78,7 +78,7 @@ {% autoescape "js" %} previewTranslations.actionControllerTitle = "{{ "Webhook Controller"|trans }}"; previewTranslations.navigateToLayout = "{{ "Navigate to layout with code [layoutTag]?"|trans }}"; - previewTranslations.emptyRegionMessage = "{{ "Empty layout"|trans }}"; + previewTranslations.emptyRegionMessage = "{{ "Empty region!"|trans }}"; previewTranslations.nextItem = "{{ "Next Item"|trans }}"; previewTranslations.previousItem = "{{ "Previous Item"|trans }}"; previewTranslations.navWidget = "{{ "Navigate to Widget"|trans }}"; diff --git a/views/common.twig b/views/common.twig index 1e359bf780..60334190f9 100644 --- a/views/common.twig +++ b/views/common.twig @@ -780,6 +780,12 @@ mediaTags: "{{ "Tags" |trans }}", mediaThumb: "{{ "Thumbnail" |trans }}", }, + placeholder: { + image: { + title: "{{ "Placeholder" |trans }}", + description: "{{ "Use this item to be used as a placeholder to add images." |trans }}", + } + } }; var topbarTrans = { From 8f24755dd721bdfb015edfaeab8dc8c3da1c8f87 Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Mon, 22 Apr 2024 16:48:00 +0100 Subject: [PATCH 050/203] Strict CSP (#2439) * Strict CSP with fallback. Widget HTML renderer takes care of adding nonce for previewing. --- .../apache2/sites-available/000-default.conf | 1 - lib/Controller/Widget.php | 3 +- lib/Middleware/Csp.php | 72 +++++++++++++++++++ lib/Widget/Render/WidgetHtmlRenderer.php | 23 +++++- modules/layout-preview.twig | 8 +-- reports/bandwidth-report-form.twig | 2 +- reports/bandwidth-report-preview.twig | 2 +- reports/campaign-proofofplay-report-form.twig | 2 +- .../campaign-proofofplay-report-preview.twig | 2 +- reports/display-adplays-report-form.twig | 2 +- reports/display-adplays-report-preview.twig | 2 +- reports/display-percentage-report-form.twig | 2 +- .../display-percentage-report-preview.twig | 2 +- reports/distribution-report-form.twig | 2 +- reports/distribution-report-preview.twig | 2 +- reports/libraryusage-report-form.twig | 2 +- reports/libraryusage-report-preview.twig | 2 +- reports/mobile-proofofplay-report-form.twig | 2 +- .../mobile-proofofplay-report-preview.twig | 2 +- reports/proofofplay-report-form.twig | 2 +- reports/proofofplay-report-preview.twig | 2 +- reports/summary-report-form.twig | 2 +- reports/summary-report-preview.twig | 2 +- reports/timeconnected-report-form.twig | 2 +- .../timedisconnectedsummary-report-form.twig | 2 +- ...imedisconnectedsummary-report-preview.twig | 2 +- views/applications-page.twig | 2 +- views/auditlog-page.twig | 2 +- views/authed.twig | 4 +- views/base-install.twig | 6 +- views/base.twig | 34 ++++----- views/campaign-builder.twig | 4 +- views/campaign-page.twig | 2 +- views/campaign-preview.twig | 6 +- views/command-page.twig | 2 +- views/common.twig | 2 +- views/dashboard-status-page.twig | 2 +- views/dataset-column-page.twig | 2 +- views/dataset-data-connector-page.twig | 2 +- views/dataset-data-connector-test-page.twig | 4 +- views/dataset-dataentry-page.twig | 2 +- views/dataset-page.twig | 2 +- views/dataset-rss-page.twig | 2 +- views/daypart-page.twig | 2 +- views/developer-template-edit-page.twig | 2 +- views/developer-template-page.twig | 2 +- views/display-page-manage.twig | 2 +- views/display-page.twig | 2 +- views/displaygroup-page.twig | 2 +- .../displayprofile-form-edit-javascript.twig | 2 +- views/displayprofile-page.twig | 2 +- views/fault-page.twig | 4 +- views/folders-page.twig | 2 +- views/fonts-page.twig | 2 +- views/help-page.twig | 2 +- views/include-file-upload.twig | 26 +++---- views/layout-designer-page.twig | 8 +-- views/layout-page.twig | 2 +- views/library-page.twig | 2 +- views/log-page.twig | 2 +- views/login.twig | 6 +- views/media-manager-page.twig | 2 +- views/menuboard-category-page.twig | 2 +- views/menuboard-page.twig | 2 +- views/menuboard-product-javascript.twig | 2 +- views/menuboard-product-page.twig | 2 +- views/module-page.twig | 2 +- views/non-authed.twig | 4 +- views/notification-page.twig | 2 +- views/playersoftware-page.twig | 2 +- views/playlist-dashboard.twig | 4 +- views/playlist-form-timeline.twig | 2 +- views/playlist-page.twig | 4 +- views/region-designer-javascript.twig | 2 +- views/report-page.twig | 2 +- views/report-schedule-page.twig | 2 +- views/resolution-page.twig | 2 +- views/saved-report-page.twig | 2 +- views/schedule-page.twig | 2 +- views/sessions-page.twig | 2 +- views/settings-page.twig | 2 +- views/syncgroup-page.twig | 2 +- views/tag-page.twig | 2 +- views/task-page.twig | 2 +- views/template-page.twig | 2 +- views/tfa.twig | 6 +- views/transition-page.twig | 2 +- views/upgrade-in-progress-page.twig | 4 +- views/user-force-change-password-page.twig | 2 +- views/user-page.twig | 2 +- views/usergroup-page.twig | 2 +- ...bo-audience-connector-form-javascript.twig | 2 +- views/xibo-ssp-connector-form-javascript.twig | 2 +- web/index.php | 5 +- 94 files changed, 236 insertions(+), 146 deletions(-) create mode 100644 lib/Middleware/Csp.php diff --git a/docker/etc/apache2/sites-available/000-default.conf b/docker/etc/apache2/sites-available/000-default.conf index 1f99fe02f8..762f518aa6 100644 --- a/docker/etc/apache2/sites-available/000-default.conf +++ b/docker/etc/apache2/sites-available/000-default.conf @@ -46,7 +46,6 @@ ServerTokens OS # Hardening Header always append X-Frame-Options SAMEORIGIN Header always append X-Content-Type-Options nosniff - Header always append Content-Security-Policy "frame-ancestors 'self'" # Alias /xibo /var/www/cms/web diff --git a/lib/Controller/Widget.php b/lib/Controller/Widget.php index 3d1a5224e3..423182e12e 100644 --- a/lib/Controller/Widget.php +++ b/lib/Controller/Widget.php @@ -1322,7 +1322,8 @@ public function getResource(Request $request, Response $response, $regionId, $id $resource, function (string $route, array $data, array $params = []) use ($request) { return $this->urlFor($request, $route, $data, $params); - } + }, + $request, ); $response->getBody()->write($resource); diff --git a/lib/Middleware/Csp.php b/lib/Middleware/Csp.php new file mode 100644 index 0000000000..e46bb514a0 --- /dev/null +++ b/lib/Middleware/Csp.php @@ -0,0 +1,72 @@ +. + */ + +namespace Xibo\Middleware; + +use Psr\Container\ContainerInterface; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use Psr\Http\Server\MiddlewareInterface as Middleware; +use Psr\Http\Server\RequestHandlerInterface as RequestHandler; +use Xibo\Helper\Random; + +/** + * CSP middleware to output CSP headers and add a CSP nonce to the view layer. + */ +class Csp implements Middleware +{ + public function __construct(private readonly ContainerInterface $container) + { + } + + /** + * Call middleware + * @param Request $request + * @param RequestHandler $handler + * @return Response + * @throws \Psr\Container\ContainerExceptionInterface + * @throws \Psr\Container\NotFoundExceptionInterface + */ + public function process(Request $request, RequestHandler $handler): Response + { + // Generate a nonce + $nonce = Random::generateString(8); + + // Create CSP header + $csp = 'object-src \'none\'; script-src \'nonce-' . $nonce . '\''; + $csp .= ' \'unsafe-inline\' \'unsafe-eval\' \'strict-dynamic\' https: http:;'; + $csp .= ' base-uri \'self\';'; + $csp .= ' frame-ancestors \'self\';'; + + // Store it for use in the stack if needed + $request = $request->withAttribute('cspNonce', $nonce); + + // Assign it to our view + $this->container->get('view')->offsetSet('cspNonce', $nonce); + + // Call next middleware. + $response = $handler->handle($request); + + // Add our header + return $response->withAddedHeader('Content-Security-Policy', $csp); + } +} diff --git a/lib/Widget/Render/WidgetHtmlRenderer.php b/lib/Widget/Render/WidgetHtmlRenderer.php index 64f61a24b4..d579473483 100644 --- a/lib/Widget/Render/WidgetHtmlRenderer.php +++ b/lib/Widget/Render/WidgetHtmlRenderer.php @@ -25,6 +25,7 @@ use Carbon\Carbon; use FilesystemIterator; use Illuminate\Support\Str; +use Psr\Http\Message\RequestInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Slim\Views\Twig; @@ -269,10 +270,15 @@ public function renderOrCache( * @param \Xibo\Entity\Region $region * @param string $output * @param callable $urlFor + * @param \Psr\Http\Message\ServerRequestInterface $request * @return string */ - public function decorateForPreview(Region $region, string $output, callable $urlFor): string - { + public function decorateForPreview( + Region $region, + string $output, + callable $urlFor, + RequestInterface $request + ): string { $matches = []; preg_match_all('/\[\[(.*?)\]\]/', $output, $matches); foreach ($matches[1] as $match) { @@ -319,7 +325,18 @@ public function decorateForPreview(Region $region, string $output, callable $url ); } } - return $output; + + // Handle CSP in preview + $html = new \DOMDocument(); + $html->loadHTML($output); + foreach ($html->getElementsByTagName('script') as $node) { + // We add this requests cspNonce to every script tag + if ($node instanceof \DOMElement) { + $node->setAttribute('nonce', $request->getAttribute('cspNonce')); + } + } + + return $html->saveHTML(); } /** diff --git a/modules/layout-preview.twig b/modules/layout-preview.twig index 86c6ad0bd4..a10e36d6c3 100644 --- a/modules/layout-preview.twig +++ b/modules/layout-preview.twig @@ -37,10 +37,10 @@ {% include 'layout-preview-partial.twig' with {'layout': layout, 'previewOptions': previewOptions} %} {# Import JS bundle from dist #} - - - - + + + {% endverbatim %} - - + @@ -50,7 +50,7 @@
- - + + \ No newline at end of file diff --git a/views/base.twig b/views/base.twig index f3a6cae4f6..a58c2894da 100644 --- a/views/base.twig +++ b/views/base.twig @@ -10,7 +10,7 @@ {# Import CSS bundle from dist #} - + {# Import user made CSS from theme #} @@ -26,49 +26,49 @@ {% block content %}{% endblock %} - {# Import JS bundle from dist #} - + {# Import JS system tools #} - + {# Import JS templates #} - + {# Import libraries copied to dist #} - - + + {# Import XIBO js files #} - - - - - + + + + + {# Dates #} {% if settings.CALENDAR_TYPE == "Jalali" %} - - + {% else %} - + {% endif %} {# Handle the inclusion of i18n #} {% set calendarTranslation %}dist/vendor/calendar/js/language/{{ translate.jsShortLocale }}.js{% endset %} {% if theme.fileExists(calendarTranslation) %} - + {% endif %} - - + - - + + - diff --git a/views/dataset-dataentry-page.twig b/views/dataset-dataentry-page.twig index 5815051bbc..e35ba70e13 100644 --- a/views/dataset-dataentry-page.twig +++ b/views/dataset-dataentry-page.twig @@ -76,7 +76,7 @@ {% endblock %} {% block javaScript %} - {% endverbatim %} - - - - - - - - - - - - + + + + + + + + + + + {# Our implementation of blueimp #} - + {# Max image resize size from settings #} - diff --git a/views/layout-designer-page.twig b/views/layout-designer-page.twig index 4ac2c62790..6ad6d85883 100644 --- a/views/layout-designer-page.twig +++ b/views/layout-designer-page.twig @@ -43,9 +43,9 @@ {# Add common file #} {% include "common.twig" %} - - - + + - + Label Colour #333 + + Warning Background Colour + darkgoldenrod + + + Finished Background Colour + darkred + @@ -229,22 +237,14 @@ body { font-size: 30px; } -.warning>#clockdiv>div>span { - border-color: darkgoldenrod; - color: darkgoldenrod; -} - -.finished>#clockdiv>div>span { - border-color: darkred; - color: darkred; -} - {% if textColor %}#clockdiv div>span { color: {{textColor}} !important; }{% endif %} {% if fontFamily %}#clockdiv div>span { font-family: {{fontFamily}} !important; }{% endif %} {% if textBackgroundColor %}#clockdiv div>span { background-color: {{textBackgroundColor}} !important; }{% endif %} {% if borderColor %}#clockdiv div>span { border-color: {{borderColor}} !important; }{% endif %} {% if labelTextColor %}#clockdiv { color: {{labelTextColor}} !important; }{% endif %} {% if labelFontFamily %}#clockdiv { font-family: {{labelFontFamily}} !important; }{% endif %} +{% if warningBackgroundColor %}.warning #clockdiv > div > span {border-color: {{warningBackgroundColor}} !important;color: {{warningBackgroundColor}} !important;}{% endif %} +{% if finishedBackgroundColor %}.finished #clockdiv > div > span {border-color: {{finishedBackgroundColor}} !important;color: {{finishedBackgroundColor}} !important;}{% endif %} ]]> - - Warning Duration - The countdown will show in a warning mode from the end duration entered. - - - - 1 - 2 - - - - - - 2 - - - - 1 - - - - - 0 - - - - - - Warning Date - The countdown will show in a warning mode from the warning date entered. - - - - 3 - - - - - 3 - - - - - Horizontal Align How should this widget be horizontally aligned? diff --git a/modules/countdown-text.xml b/modules/countdown-text.xml index c3a4b53227..fa35d721b7 100755 --- a/modules/countdown-text.xml +++ b/modules/countdown-text.xml @@ -192,14 +192,8 @@ body { {% if fontFamily %}.hoursAll, .hours, .minutes, .seconds { font-family: {{fontFamily}}; }{% endif %} {% if textColor %}.hoursAll, .hours, .minutes, .seconds { color: {{textColor}}; }{% endif %} {% if dividerTextColor %}.countdown-container > p > span { color: {{dividerTextColor}}; }{% endif %} - -.warning .seconds, .warning .minutes, .warning .hours, .warning .hoursAll { - color: {{warningColor}}; -} - -.finished .seconds, .finished .minutes, .finished .hours, .finished .hoursAll { - color: {{finishedColor}}; -} +{% if warningColor %}.warning .seconds, .warning .minutes, .warning .hours, .warning .hoursAll {color: {{warningColor}};}{% endif %} +{% if finishedColor %}.finished .seconds, .finished .minutes, .finished .hours, .finished .hoursAll {color: {{finishedColor}};}{% endif %} ]]> Date: Thu, 25 Apr 2024 10:22:37 +0100 Subject: [PATCH 057/203] Toolbar levels: Adjust form controls width accordingly (#2500) relates to xibosignageltd/xibo-private#688 --- ui/src/style/toolbar.scss | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/ui/src/style/toolbar.scss b/ui/src/style/toolbar.scss index c9065db7fb..6cd26c77f4 100644 --- a/ui/src/style/toolbar.scss +++ b/ui/src/style/toolbar.scss @@ -1090,9 +1090,8 @@ width: 100% !important; } - /* Filter form */ - form.media-search-form, form.layout_tempates-search-form { + form.toolbar-search-form { .form-group { width: 100%; } @@ -1109,6 +1108,18 @@ .toolbar-card:not(tr), .toolbar-card-sizer { width: calc(calc(100%/2) - 3px) !important; } + + /* Filter form */ + form.toolbar-search-form { + display: flex; + gap: 6px; + flex-wrap: wrap; + + .form-group { + flex: 1 1 calc(50% - 3px); + min-width: calc(50% - 3px); + } + } } } @@ -1121,6 +1132,18 @@ .toolbar-card:not(tr), .toolbar-card-sizer { width: calc(calc(100%/3) - 4px) !important; } + + /* Filter form */ + form.toolbar-search-form { + display: flex; + gap: 6px; + flex-wrap: wrap; + + .form-group { + flex: 1 1 calc(50% - 3px); + min-width: calc(50% - 3px); + } + } } } @@ -1133,6 +1156,18 @@ .toolbar-card:not(tr), .toolbar-card-sizer { width: calc(calc(100%/4) - 4.5px) !important; } + + /* Filter form */ + form.toolbar-search-form { + display: flex; + gap: 6px; + flex-wrap: wrap; + + .form-group { + flex: 1 1 calc(25% - 4.5px); + min-width: calc(25% - 4.5px); + } + } } } } From 4ba31e19d5c8eb3eef43caa634284735bb7edb9c Mon Sep 17 00:00:00 2001 From: Peter Date: Fri, 26 Apr 2024 12:08:50 +0100 Subject: [PATCH 058/203] Layout Exchange : Improve filters, add concept of featured templates. (#2501) * Layout Exchange : Improve filters, add concept of featured templates. --- lib/Connector/XiboExchangeConnector.php | 37 +++++++++++-- lib/Controller/Template.php | 74 +++++++++++++++++++++---- lib/Entity/SearchResult.php | 4 +- lib/Event/TemplateProviderEvent.php | 14 ++++- ui/src/editor-core/toolbar.js | 6 ++ 5 files changed, 117 insertions(+), 18 deletions(-) diff --git a/lib/Connector/XiboExchangeConnector.php b/lib/Connector/XiboExchangeConnector.php index 111d0574d7..62ea7692a6 100644 --- a/lib/Connector/XiboExchangeConnector.php +++ b/lib/Connector/XiboExchangeConnector.php @@ -136,16 +136,32 @@ public function onTemplateProvider(TemplateProviderEvent $event) $providerDetails->message = $this->getTitle(); $providerDetails->backgroundColor = ''; + // parse the templates based on orientation filter. + if (!empty($event->getOrientation())) { + $templates = []; + foreach ($body as $template) { + if (!empty($template->orientation) && + Str::contains($template->orientation, $event->getOrientation(), true) + ) { + $templates[] = $template; + } + } + } else { + $templates = $body; + } + // Filter the body based on search param. if (!empty($event->getSearch())) { $filtered = []; - foreach ($body as $template) { - if (Str::contains($template->title, $event->getSearch())) { + foreach ($templates as $template) { + if (Str::contains($template->title, $event->getSearch(), true)) { $filtered[] = $template; continue; } - if (!empty($template->description) && Str::contains($template->description, $event->getSearch())) { + if (!empty($template->description) && + Str::contains($template->description, $event->getSearch(), true) + ) { $filtered[] = $template; continue; } @@ -157,9 +173,18 @@ public function onTemplateProvider(TemplateProviderEvent $event) } } } else { - $filtered = $body; + $filtered = $templates; } + // sort, featured first, otherwise alphabetically. + usort($filtered, function ($a, $b) { + if (property_exists($a, 'isFeatured') && property_exists($b, 'isFeatured')) { + return $b->isFeatured <=> $a->isFeatured; + } else { + return $a->title <=> $b->title; + } + }); + for ($i = $start; $i < ($start + $perPage - 1) && $i < count($filtered); $i++) { $searchResult = $this->createSearchResult($filtered[$i]); $searchResult->provider = $providerDetails; @@ -205,6 +230,10 @@ private function createSearchResult($template) : SearchResult $searchResult->orientation = $template->orientation; } + if (property_exists($template, 'isFeatured')) { + $searchResult->isFeatured = $template->isFeatured; + } + // Thumbnail $searchResult->thumbnail = $template->thumbnailUrl; $searchResult->download = $template->downloadUrl; diff --git a/lib/Controller/Template.php b/lib/Controller/Template.php index 6ac0357797..36dc319fc7 100644 --- a/lib/Controller/Template.php +++ b/lib/Controller/Template.php @@ -113,7 +113,9 @@ public function grid(Request $request, Response $response) { $sanitizedQueryParams = $this->getSanitizer($request->getQueryParams()); // Embed? - $embed = ($sanitizedQueryParams->getString('embed') != null) ? explode(',', $sanitizedQueryParams->getString('embed')) : []; + $embed = ($sanitizedQueryParams->getString('embed') != null) + ? explode(',', $sanitizedQueryParams->getString('embed')) + : []; $templates = $this->layoutFactory->query($this->gridRenderSort($sanitizedQueryParams), $this->gridRenderFilter([ 'excludeTemplates' => 0, @@ -155,18 +157,25 @@ public function grid(Request $request, Response $response) } // Parse down for description - $template->setUnmatchedProperty('descriptionWithMarkup', Parsedown::instance()->text($template->description)); + $template->setUnmatchedProperty( + 'descriptionWithMarkup', + Parsedown::instance()->text($template->description) + ); if ($this->getUser()->featureEnabled('template.modify') && $this->getUser()->checkEditable($template) ) { // Design Button - $template->buttons[] = array( + $template->buttons[] = [ 'id' => 'layout_button_design', 'linkType' => '_self', 'external' => true, - 'url' => $this->urlFor($request, 'layout.designer', array('id' => $template->layoutId)) . '?isTemplateEditor=1', + 'url' => $this->urlFor( + $request, + 'layout.designer', + ['id' => $template->layoutId] + ) . '?isTemplateEditor=1', 'text' => __('Alter Template') - ); + ]; if ($template->isEditable()) { $template->buttons[] = ['divider' => true]; @@ -194,7 +203,14 @@ public function grid(Request $request, Response $response) 'text' => __('Checkout'), 'dataAttributes' => [ ['name' => 'auto-submit', 'value' => true], - ['name' => 'commit-url', 'value' => $this->urlFor($request, 'layout.checkout', ['id' => $template->layoutId])], + [ + 'name' => 'commit-url', + 'value' => $this->urlFor( + $request, + 'layout.checkout', + ['id' => $template->layoutId] + ) + ], ['name' => 'commit-method', 'value' => 'PUT'] ] ); @@ -217,7 +233,14 @@ public function grid(Request $request, Response $response) 'text' => __('Select Folder'), 'multi-select' => true, 'dataAttributes' => [ - ['name' => 'commit-url', 'value' => $this->urlFor($request, 'campaign.selectfolder', ['id' => $template->campaignId])], + [ + 'name' => 'commit-url', + 'value' => $this->urlFor( + $request, + 'campaign.selectfolder', + ['id' => $template->campaignId] + ) + ], ['name' => 'commit-method', 'value' => 'put'], ['name' => 'id', 'value' => 'campaign_button_selectfolder'], ['name' => 'text', 'value' => __('Move to Folder')], @@ -245,7 +268,14 @@ public function grid(Request $request, Response $response) 'text' => __('Delete'), 'multi-select' => true, 'dataAttributes' => [ - ['name' => 'commit-url', 'value' => $this->urlFor($request, 'layout.delete', ['id' => $template->layoutId])], + [ + 'name' => 'commit-url', + 'value' => $this->urlFor( + $request, + 'layout.delete', + ['id' => $template->layoutId] + ) + ], ['name' => 'commit-method', 'value' => 'delete'], ['name' => 'id', 'value' => 'layout_button_delete'], ['name' => 'text', 'value' => __('Delete')], @@ -263,18 +293,36 @@ public function grid(Request $request, Response $response) // Permissions button $template->buttons[] = [ 'id' => 'layout_button_permissions', - 'url' => $this->urlFor($request, 'user.permissions.form', ['entity' => 'Campaign', 'id' => $template->campaignId]) . '?nameOverride=' . __('Template'), + 'url' => $this->urlFor( + $request, + 'user.permissions.form', + ['entity' => 'Campaign', 'id' => $template->campaignId] + ) . '?nameOverride=' . __('Template'), 'text' => __('Share'), 'multi-select' => true, 'dataAttributes' => [ - ['name' => 'commit-url', 'value' => $this->urlFor($request, 'user.permissions.multi', ['entity' => 'Campaign', 'id' => $template->campaignId])], + [ + 'name' => 'commit-url', + 'value' => $this->urlFor( + $request, + 'user.permissions.multi', + ['entity' => 'Campaign', 'id' => $template->campaignId] + ) + ], ['name' => 'commit-method', 'value' => 'post'], ['name' => 'id', 'value' => 'layout_button_permissions'], ['name' => 'text', 'value' => __('Share')], ['name' => 'rowtitle', 'value' => $template->layout], ['name' => 'sort-group', 'value' => 2], ['name' => 'custom-handler', 'value' => 'XiboMultiSelectPermissionsFormOpen'], - ['name' => 'custom-handler-url', 'value' => $this->urlFor($request, 'user.permissions.multi.form', ['entity' => 'Campaign'])], + [ + 'name' => 'custom-handler-url', + 'value' => $this->urlFor( + $request, + 'user.permissions.multi.form', + ['entity' => 'Campaign'] + ) + ], ['name' => 'content-id-name', 'value' => 'campaignId'] ] ]; @@ -335,6 +383,7 @@ public function search(Request $request, Response $response) 'excludeTemplates' => 0, 'layout' => $sanitizedQueryParams->getString('template'), 'folderId' => $sanitizedQueryParams->getInt('folderId'), + 'orientation' => $sanitizedQueryParams->getString('orientation', ['defaultOnEmptyString' => true]), 'publishedStatusId' => 1 ], $sanitizedQueryParams)); @@ -382,7 +431,8 @@ public function search(Request $request, Response $response) $searchResults, $sanitizedQueryParams->getInt('start', ['default' => 0]), $sanitizedQueryParams->getInt('length', ['default' => 15]), - $sanitizedQueryParams->getString('template') + $sanitizedQueryParams->getString('template'), + $sanitizedQueryParams->getString('orientation'), ); $this->getLog()->debug('Dispatching event. ' . $event->getName()); diff --git a/lib/Entity/SearchResult.php b/lib/Entity/SearchResult.php index 14bbeb5b9b..01fd4a741f 100644 --- a/lib/Entity/SearchResult.php +++ b/lib/Entity/SearchResult.php @@ -42,6 +42,7 @@ class SearchResult implements \JsonSerializable public $duration; public $videoThumbnailUrl; public $tags = []; + public $isFeatured = 0; /** @var ProviderDetails */ public $provider; @@ -63,7 +64,8 @@ public function jsonSerialize(): array 'orientation' => $this->orientation, 'fileSize' => $this->fileSize, 'videoThumbnailUrl' => $this->videoThumbnailUrl, - 'tags' => $this->tags + 'tags' => $this->tags, + 'isFeatured' => $this->isFeatured ]; } } diff --git a/lib/Event/TemplateProviderEvent.php b/lib/Event/TemplateProviderEvent.php index a43a7578ae..4905c707ac 100644 --- a/lib/Event/TemplateProviderEvent.php +++ b/lib/Event/TemplateProviderEvent.php @@ -48,17 +48,21 @@ class TemplateProviderEvent extends Event /** @var string|null */ private $search; + /** @var string|null */ + private $orientation; + /** * @param \Xibo\Entity\SearchResults $results * @param int $start * @param int $length */ - public function __construct(SearchResults $results, int $start, int $length, ?string $search) + public function __construct(SearchResults $results, int $start, int $length, ?string $search, ?string $orientation) { $this->results = $results; $this->start = $start; $this->length = $length; $this->search = $search; + $this->orientation = $orientation; } /** @@ -104,4 +108,12 @@ public function getSearch(): ?string { return $this->search; } + + /** + * @return string|null + */ + public function getOrientation(): ?string + { + return $this->orientation; + } } diff --git a/ui/src/editor-core/toolbar.js b/ui/src/editor-core/toolbar.js index e51dc5731b..990859b62a 100644 --- a/ui/src/editor-core/toolbar.js +++ b/ui/src/editor-core/toolbar.js @@ -560,6 +560,9 @@ Toolbar.prototype.init = function({isPlaylist = false} = {}) { value: 'local', locked: true, }, + orientation: { + value: '', + }, }, state: '', itemCount: 0, @@ -580,6 +583,9 @@ Toolbar.prototype.init = function({isPlaylist = false} = {}) { value: 'remote', locked: true, }, + orientation: { + value: '', + }, }, state: '', itemCount: 0, From 23e2a9251c77f514acfca0cfe9ce61fa9850262c Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Fri, 26 Apr 2024 12:53:54 +0100 Subject: [PATCH 059/203] CSP: fix for warnings raised by DOMDocument. (#2506) xibosignageltd/xibo-private#654 --- lib/Widget/Render/WidgetHtmlRenderer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Widget/Render/WidgetHtmlRenderer.php b/lib/Widget/Render/WidgetHtmlRenderer.php index d579473483..fee65209ab 100644 --- a/lib/Widget/Render/WidgetHtmlRenderer.php +++ b/lib/Widget/Render/WidgetHtmlRenderer.php @@ -328,7 +328,7 @@ public function decorateForPreview( // Handle CSP in preview $html = new \DOMDocument(); - $html->loadHTML($output); + $html->loadHTML($output, LIBXML_NOERROR | LIBXML_NOWARNING); foreach ($html->getElementsByTagName('script') as $node) { // We add this requests cspNonce to every script tag if ($node instanceof \DOMElement) { From 5a25d57c4989e53a8a1ad214a8d9c58cda462755 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mauro=20Ferr=C3=A3o?= Date: Sun, 28 Apr 2024 18:38:05 +0100 Subject: [PATCH 060/203] Weather: Change Background Image element name (#2503) relates to xibosignageltd/xibo-private#701 --- modules/templates/forecast-elements.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/templates/forecast-elements.xml b/modules/templates/forecast-elements.xml index e317d42d8c..2578bc8d7d 100644 --- a/modules/templates/forecast-elements.xml +++ b/modules/templates/forecast-elements.xml @@ -238,7 +238,7 @@ if (meta && meta.hasOwnProperty('Attribution')) { diff --git a/modules/templates/global-elements.xml b/modules/templates/global-elements.xml index 61c1d46d36..c984bd1569 100644 --- a/modules/templates/global-elements.xml +++ b/modules/templates/global-elements.xml @@ -46,6 +46,11 @@ + + Fit to selection + Fit to selected area instead of using the font size? + 0 + Use gradient for the text? 0 @@ -62,10 +67,20 @@ Font Size 40 + + + 1 + + Line Height 1.2 + + + 1 + + Bold @@ -86,14 +101,20 @@ Text Wrap Should the text wrap to the next line? 1 + + + 1 + + Justify Should the text be justified? 0 - + 1 + 1 @@ -101,6 +122,11 @@ Show Overflow Should the widget overflow the region? 1 + + + 1 + + Text Shadow @@ -145,6 +171,11 @@ Horizontal Align center + + + 1 + + @@ -167,7 +198,6 @@ style=" display: flex; {{#if fontFamily}}font-family: {{fontFamily}};{{/if}} - {{#if fontSize}}font-size: {{fontSize}}px;{{/if}} {{#if useGradient}} {{parseJSON "gb" gradient}} background-clip: text; @@ -183,25 +213,29 @@ {{#if bold}}font-weight: bold;{{/if}} {{#if italics}}font-style: italic;{{/if}} {{#if underline}}text-decoration: underline;{{/if}} - {{#if showOverflow}}overflow: visible;{{else}}overflow: hidden;{{/if}} - {{#if horizontalAlign}} - justify-content: {{horizontalAlign}}; - {{/if}} - {{#eq horizontalAlign "flex-start"}}text-align: left;{{/eq}} - {{#eq horizontalAlign "center"}}text-align: center;{{/eq}} - {{#eq horizontalAlign "flex-end"}}text-align: right;{{/eq}} {{#if verticalAlign}}align-items: {{verticalAlign}};{{/if}} - {{#if textWrap}} - white-space: break-spaces; - {{#if justify}} - text-align: justify; - {{/if}} - {{else}} + {{#if fitToArea}} white-space: nowrap; + {{else}} + {{#if fontSize}}font-size: {{fontSize}}px;{{/if}} + {{#if showOverflow}}overflow: visible;{{else}}overflow: hidden;{{/if}} + {{#if horizontalAlign}} + justify-content: {{horizontalAlign}}; + {{/if}} + {{#eq horizontalAlign "flex-start"}}text-align: left;{{/eq}} + {{#eq horizontalAlign "center"}}text-align: center;{{/eq}} + {{#eq horizontalAlign "flex-end"}}text-align: right;{{/eq}} + {{#if lineHeight}}line-height: {{lineHeight}};{{/if}} + {{#if textWrap}} + white-space: break-spaces; + {{#if justify}} + text-align: justify; + {{/if}} + {{else}} + white-space: nowrap; + {{/if}} {{/if}} - {{#if lineHeight}}line-height: {{lineHeight}};{{/if}} {{#if verticalAlign}}align-items: {{verticalAlign}};{{/if}} - {{#if textWrap}}white-space: break-spaces;{{else}}white-space: nowrap;{{/if}} {{#if textShadow}}text-shadow: {{shadowX}}px {{shadowY}}px {{shadowBlur}}px {{textShadowColor}};{{/if}} width: 100%; height: 100%;" > @@ -212,6 +246,15 @@ {{/if}}
]]> + @@ -253,6 +296,11 @@ + + Fit to selection + Fit to selected area instead of using the font size? + 0 + Use gradient for the text? 0 @@ -269,10 +317,20 @@ Font Size 40 + + + 1 + + Line Height 1.2 + + + 1 + + Bold @@ -293,11 +351,21 @@ Text Wrap Should the text wrap to the next line? 1 + + + 1 + + Show Overflow Should the widget overflow the region? 1 + + + 1 + + Text Shadow @@ -342,6 +410,11 @@ Horizontal Align center + + + 1 + + @@ -364,8 +437,6 @@ style=" display: flex; {{#if fontFamily}}font-family: {{fontFamily}};{{/if}} - {{#if fontSize}}font-size: {{fontSize}}px;{{/if}} - {{#if lineHeight}}line-height: {{lineHeight}};{{/if}} {{#if useGradient}} {{parseJSON "gb" gradient}} background-clip: text; @@ -382,9 +453,15 @@ {{#if italics}}font-style: italic;{{/if}} {{#if underline}}text-decoration: underline;{{/if}} {{#if showOverflow}}overflow: visible;{{else}}overflow: hidden;{{/if}} - {{#if horizontalAlign}}justify-content: {{horizontalAlign}};{{/if}} {{#if verticalAlign}}align-items: {{verticalAlign}};{{/if}} + {{#if fitToArea}} + white-space: nowrap; + {{else}} + {{#if fontSize}}font-size: {{fontSize}}px;{{/if}} + {{#if lineHeight}}line-height: {{lineHeight}};{{/if}} + {{#if horizontalAlign}}justify-content: {{horizontalAlign}};{{/if}} {{#if textWrap}}white-space: break-spaces;{{else}}white-space: nowrap;{{/if}} + {{/if}} {{#if textShadow}}text-shadow: {{shadowX}}px {{shadowY}}px {{shadowBlur}}px {{textShadowColor}};{{/if}} width: 100%; height: 100%;" > @@ -414,6 +491,15 @@ $(target).find('.date').each(function(_idx, dateEl){ // Set the date div value to the formatted date $(dateEl).html(formattedDate); + + + if (properties.fitToArea) { + // Set target for the text + properties.fitTarget = '.date'; + + // Scale text to container + $(target).find('.global-elements-date').xiboTextScaler(properties); + } }); ]]> @@ -476,6 +562,11 @@ $(target).find('.date').each(function(_idx, dateEl){ + + Fit to selection + Fit to selected area instead of using the font size? + 0 + Use gradient for the text? 0 @@ -492,10 +583,20 @@ $(target).find('.date').each(function(_idx, dateEl){ Font Size 40 + + + 1 + + Line Height 1.2 + + + 1 + + Bold @@ -516,6 +617,11 @@ $(target).find('.date').each(function(_idx, dateEl){ Text Wrap Should the text wrap to the next line? 1 + + + 1 + + Show Overflow @@ -565,6 +671,11 @@ $(target).find('.date').each(function(_idx, dateEl){ Horizontal Align center + + + 1 + + @@ -587,8 +698,6 @@ $(target).find('.date').each(function(_idx, dateEl){ style=" display: flex; {{#if fontFamily}}font-family: {{fontFamily}};{{/if}} - {{#if fontSize}}font-size: {{fontSize}}px;{{/if}} - {{#if lineHeight}}line-height: {{lineHeight}};{{/if}} {{#if useGradient}} {{parseJSON "gb" gradient}} background-clip: text; @@ -604,10 +713,16 @@ $(target).find('.date').each(function(_idx, dateEl){ {{#if bold}}font-weight: bold;{{/if}} {{#if italics}}font-style: italic;{{/if}} {{#if underline}}text-decoration: underline;{{/if}} - {{#if showOverflow}}overflow: visible;{{else}}overflow: hidden;{{/if}} - {{#if horizontalAlign}}justify-content: {{horizontalAlign}};{{/if}} + {{#if fitToArea}} + white-space: nowrap; + {{else}} + {{#if fontSize}}font-size: {{fontSize}}px;{{/if}} + {{#if lineHeight}}line-height: {{lineHeight}};{{/if}} + {{#if showOverflow}}overflow: visible;{{else}}overflow: hidden;{{/if}} + {{#if horizontalAlign}}justify-content: {{horizontalAlign}};{{/if}} + {{#if textWrap}}white-space: break-spaces;{{else}}white-space: nowrap;{{/if}} + {{/if}} {{#if verticalAlign}}align-items: {{verticalAlign}};{{/if}} - {{#if textWrap}}white-space: break-spaces;{{else}}white-space: nowrap;{{/if}} {{#if textShadow}}text-shadow: {{shadowX}}px {{shadowY}}px {{shadowBlur}}px {{textShadowColor}};{{/if}} width: 100%; height: 100%;" > @@ -622,6 +737,8 @@ var useCurrentDate = (properties.currentDate == 1); if (useCurrentDate) { // Use current date + let firstRun = true; + var offset = properties.offset || 0; // Clear previous interval if exists if (window['updateInterval_' + id]) { @@ -645,6 +762,16 @@ if (useCurrentDate) { } $dateEl.html(currentDate.format(properties.dateFormat)); + + if (firstRun && properties.fitToArea) { + // Set target for the text + properties.fitTarget = '.date-advanced'; + + // Scale text to container + $(target).find('.global-elements-date-advanced').xiboTextScaler(properties); + + firstRun = false; + } }, 1000); } else { // Use custom date @@ -667,6 +794,14 @@ if (useCurrentDate) { // Set the date div value to the formatted date $dateEl.html(formattedDate); + + if (properties.fitToArea) { + // Set target for the text + properties.fitTarget = '.date-advanced'; + + // Scale text to container + $(target).find('.global-elements-date-advanced').xiboTextScaler(properties); + } } ]]> diff --git a/modules/templates/product-elements.xml b/modules/templates/product-elements.xml index 491f972235..562a3faed4 100644 --- a/modules/templates/product-elements.xml +++ b/modules/templates/product-elements.xml @@ -48,6 +48,14 @@ if (properties.dimWhenUnavailable && properties.data && properties.data.availability === 0) { $(target).find('div:first').css('color', properties.dimColor); } + +if(properties.fitToArea) { + // Set target for the text + properties.fitTarget = 'div'; + + // Scale text to container + $(target).find('.global-elements-text').xiboTextScaler(properties); +} ]]> @@ -249,9 +284,19 @@ if ( Option slot 1 + + Fit to selection + Fit to selected area instead of using the font size? + 0 + Font Size 24 + + + 1 + + Font Colour @@ -273,6 +318,11 @@ if ( Horizontal Align center + + + 1 + + @@ -294,18 +344,22 @@ if (
+>
]]> @@ -344,7 +398,15 @@ if ( value = value + '' + properties.suffix; } - $productContainer.append(value); + $productContainer.find('div').append(value); +} + +if(properties.fitToArea) { + // Set target for the text + properties.fitTarget = 'div'; + + // Scale text to container + $(target).find('.product-elements-options-value').xiboTextScaler(properties); } ]]> @@ -381,6 +443,8 @@ if (
+ + diff --git a/views/common.twig b/views/common.twig index 624ee6521b..536d4152f8 100644 --- a/views/common.twig +++ b/views/common.twig @@ -619,7 +619,7 @@ backgroundColor: "{{ "Background Colour" |trans }}", backgroundColorHelpText: "{{ "Use the colour picker to select the background colour" |trans }}", backgroundImage: "{{ "Background Image" |trans }}", - noImageSet: "{{ "No Image set, add from Toolbar or Upload!" |trans }}", + noImageSet: "{{ "No Image set, add from Toolbox or Upload!" |trans }}", addBackgroundImage: "{{ "Add background image" |trans }}", upload: "{{ "Upload" |trans }}", remove: "{{ "Remove" |trans }}", diff --git a/views/layout-form-background.twig b/views/layout-form-background.twig index e30a834c34..599a3e4aa9 100644 --- a/views/layout-form-background.twig +++ b/views/layout-form-background.twig @@ -58,7 +58,7 @@
-
{% trans "No Image set, add from Toolbar or Upload!" %}
+
{% trans "No Image set, add from Toolbox or Upload!" %}
{% trans From 4ed36f3485b0a0159efb8e26cc3eff77b69e2d9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mauro=20Ferr=C3=A3o?= Date: Mon, 5 Aug 2024 10:35:13 +0100 Subject: [PATCH 164/203] Toolbox folder control: Save preferences and reload is broken (#2675) relates to xibosignageltd/xibo-private#823 --- ui/src/core/xibo-cms.js | 52 +++++++++++++++++++++++++++++------ ui/src/editor-core/toolbar.js | 16 +++++------ 2 files changed, 51 insertions(+), 17 deletions(-) diff --git a/ui/src/core/xibo-cms.js b/ui/src/core/xibo-cms.js index 2a5452971f..8b45fcbd2c 100644 --- a/ui/src/core/xibo-cms.js +++ b/ui/src/core/xibo-cms.js @@ -3156,8 +3156,17 @@ function ToggleFilterView(div) { * @param element * @param parent * @param dataFormatter + * @param addRandomId */ -function makePagedSelect(element, parent, dataFormatter) { +function makePagedSelect(element, parent, dataFormatter, addRandomId = false) { + // If we need to append random id + if (addRandomId === true) { + const randomNum = Math.floor(1000000000 + Math.random() * 9000000000); + const previousId = $(element).attr('id'); + const newId = previousId ? previousId + '_' + randomNum : randomNum; + $(element).attr('data-select2-id', newId); + } + element.select2({ dropdownParent: ((parent == null) ? $("body") : $(parent)), minimumResultsForSearch: (element.data('hideSearch')) ? Infinity : 1, @@ -3278,6 +3287,8 @@ function makePagedSelect(element, parent, dataFormatter) { ) { var initialValue = element.data("initialValue"); var initialKey = element.data("initialKey"); + var textProperty = element.data("textProperty"); + var idProperty = element.data("idProperty"); var dataObj = {}; dataObj[initialKey] = initialValue; @@ -3292,23 +3303,37 @@ function makePagedSelect(element, parent, dataFormatter) { type: 'GET', data: dataObj }).then(function(data) { + // Do we need to check if it's selected + var checkSelected = false; + // If we have a custom data formatter if ( dataFormatter && typeof dataFormatter === 'function' ) { data = dataFormatter(data); + checkSelected = true; } // create the option and append to Select2 data.data.forEach(object => { - var option = new Option( - object[element.data("textProperty")], - object[element.data("idProperty")], - true, - true - ); - element.append(option) + var isSelected = true; + + // Check if it's selected if needed + if(checkSelected) { + isSelected = (initialValue == object[idProperty]); + } + + // Only had if the option is selected + if (isSelected) { + var option = new Option( + object[textProperty], + object[idProperty], + isSelected, + isSelected + ); + element.append(option) + } }); // Trigger change but skip auto save @@ -3334,8 +3359,17 @@ function makePagedSelect(element, parent, dataFormatter) { * Make a dropwdown with a search field for option's text and tag datafield (data-tags) * @param element * @param parent + * @param addRandomId */ -function makeLocalSelect(element, parent) { +function makeLocalSelect(element, parent, addRandomId = false) { + // If we need to append random id + if (addRandomId === true) { + const randomNum = Math.floor(1000000000 + Math.random() * 9000000000); + const previousId = $(element).attr('id'); + const newId = previousId ? previousId + '_' + randomNum : randomNum; + $(element).attr('data-select2-id', newId); + } + element.select2({ dropdownParent: ((parent == null) ? $("body") : $(parent)), matcher: function(params, data) { diff --git a/ui/src/editor-core/toolbar.js b/ui/src/editor-core/toolbar.js index c316101455..6ce24454bd 100644 --- a/ui/src/editor-core/toolbar.js +++ b/ui/src/editor-core/toolbar.js @@ -2325,11 +2325,11 @@ Toolbar.prototype.mediaContentHandleInputs = function( // Initialize user list input const $userListInput = $mediaForm.find('select[name="ownerId"]'); - makePagedSelect($userListInput); + makePagedSelect($userListInput, $mediaContainer, null, true); // Initialize folder input const $folderInput = $mediaForm.find('select[name="folderId"]'); - makePagedSelect($folderInput, null, function(data) { + makePagedSelect($folderInput, $mediaContainer, function(data) { // Format data const newData = []; @@ -2355,7 +2355,7 @@ Toolbar.prototype.mediaContentHandleInputs = function( return { data: newData, }; - }); + }, true); // Initialize other select inputs $mediaForm @@ -2661,7 +2661,7 @@ Toolbar.prototype.layoutTemplatesContentPopulate = function(menu) { // Initialize folder input const $folderInput = $searchForm.find('select[name="folderId"]'); - makePagedSelect($folderInput, null, function(data) { + makePagedSelect($folderInput, $searchForm, function(data) { // Format data const newData = []; @@ -2687,7 +2687,7 @@ Toolbar.prototype.layoutTemplatesContentPopulate = function(menu) { return { data: newData, }; - }); + }, true); // Initialize other select inputs $container @@ -2916,11 +2916,11 @@ Toolbar.prototype.playlistsContentPopulate = function(menu) { // Initialize user list input const $userListInput = $container .find('.media-search-form select[name="userId"]'); - makePagedSelect($userListInput); + makePagedSelect($userListInput, $container, null, true); // Initialize folder input const $folderInput = $container.find('select[name="folderId"]'); - makePagedSelect($folderInput, null, function(data) { + makePagedSelect($folderInput, $container, function(data) { // Format data const newData = []; @@ -2946,7 +2946,7 @@ Toolbar.prototype.playlistsContentPopulate = function(menu) { return { data: newData, }; - }); + }, true); // Initialize other select inputs $container From 38e8516895421a56a6e337260ab60c8cca59adf7 Mon Sep 17 00:00:00 2001 From: Peter Date: Mon, 5 Aug 2024 10:45:35 +0100 Subject: [PATCH 165/203] Layout Exchange : switch endpoint to 4.1 layouts. (#2678) --- lib/Connector/XiboExchangeConnector.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Connector/XiboExchangeConnector.php b/lib/Connector/XiboExchangeConnector.php index a8d8c60a9d..d7d405bf65 100644 --- a/lib/Connector/XiboExchangeConnector.php +++ b/lib/Connector/XiboExchangeConnector.php @@ -93,7 +93,7 @@ public function onTemplateProvider(TemplateProviderEvent $event) $this->getLogger()->debug('XiboExchangeConnector: onTemplateProvider'); // Get a cache of the layouts.json file, or request one from download. - $uri = 'https://download.xibosignage.com/layouts_v4.json'; + $uri = 'https://download.xibosignage.com/layouts_v4_1.json'; $key = md5($uri); $cache = $this->getPool()->getItem($key); $body = $cache->get(); From 3660c961cf455f62a1743ecddef16764b7e1ecfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mauro=20Ferr=C3=A3o?= Date: Mon, 5 Aug 2024 14:30:09 +0100 Subject: [PATCH 166/203] Editor: Element resize and position tab issues (#2681) relates to xibosignageltd/xibo-private#821 --- ui/src/editor-core/properties-panel.js | 784 +++++++++++++------------ ui/src/layout-editor/viewer.js | 30 +- 2 files changed, 423 insertions(+), 391 deletions(-) diff --git a/ui/src/editor-core/properties-panel.js b/ui/src/editor-core/properties-panel.js index 8400e2a9ae..ba0df78edd 100644 --- a/ui/src/editor-core/properties-panel.js +++ b/ui/src/editor-core/properties-panel.js @@ -1228,456 +1228,474 @@ PropertiesPanel.prototype.render = function( isElementGroup ) ) { - // Get position - let positionProperties = {}; - if (isElementGroup) { - positionProperties = { - type: 'element-group', - top: targetAux.top, - left: targetAux.left, - width: targetAux.width, - height: targetAux.height, - zIndex: targetAux.layer, - }; - } else if (targetAux?.type === 'element') { - positionProperties = { - type: 'element', - top: targetAux.top, - left: targetAux.left, - width: targetAux.width, - height: targetAux.height, - zIndex: targetAux.layer, - }; + const renderPositionTab = function() { + // Get position + let positionProperties = {}; + if (isElementGroup) { + positionProperties = { + type: 'element-group', + top: targetAux.top, + left: targetAux.left, + width: targetAux.width, + height: targetAux.height, + zIndex: targetAux.layer, + }; + } else if (targetAux?.type === 'element') { + positionProperties = { + type: 'element', + top: targetAux.top, + left: targetAux.left, + width: targetAux.width, + height: targetAux.height, + zIndex: targetAux.layer, + }; - if (targetAux.canRotate) { - positionProperties.rotation = targetAux.rotation; + if (targetAux.canRotate) { + positionProperties.rotation = targetAux.rotation; + } + } else if (target.subType === 'playlist') { + positionProperties = { + type: 'region', + regionType: target.subType, + regionName: target.name, + top: target.dimensions.top, + left: target.dimensions.left, + width: target.dimensions.width, + height: target.dimensions.height, + zIndex: target.zIndex, + }; + } else { + positionProperties = { + type: 'region', + regionType: target.parent.subType, + regionName: target.parent.name, + top: target.parent.dimensions.top, + left: target.parent.dimensions.left, + width: target.parent.dimensions.width, + height: target.parent.dimensions.height, + zIndex: target.parent.zIndex, + }; } - } else if (target.subType === 'playlist') { - positionProperties = { - type: 'region', - regionType: target.subType, - regionName: target.name, - top: target.dimensions.top, - left: target.dimensions.left, - width: target.dimensions.width, - height: target.dimensions.height, - zIndex: target.zIndex, - }; - } else { - positionProperties = { - type: 'region', - regionType: target.parent.subType, - regionName: target.parent.name, - top: target.parent.dimensions.top, - left: target.parent.dimensions.left, - width: target.parent.dimensions.width, - height: target.parent.dimensions.height, - zIndex: target.parent.zIndex, - }; - } - // Get position template - const positionTemplate = formTemplates.position; - - // Add position tab after advanced tab - self.DOMObject.find('[href="#advancedTab"]').parent() - .after(``); - - // Add position tab content after advanced tab content - // If element is in a group, adjust position to the group's - if ( - targetAux?.type == 'element' && - targetAux?.group != undefined - ) { - positionProperties.left -= targetAux.group.left; - positionProperties.top -= targetAux.group.top; - } + // Get position template + const positionTemplate = formTemplates.position; + + // Add position tab after advanced tab + self.DOMObject.find('[href="#advancedTab"]').parent() + .after(``); + + // Add position tab content after advanced tab content + // If element is in a group, adjust position to the group's + if ( + targetAux?.type == 'element' && + targetAux?.group != undefined + ) { + positionProperties.left -= targetAux.group.left; + positionProperties.top -= targetAux.group.top; + } - // If it's an element, or element group, show canvas layer - if ( - targetAux?.type == 'element' || - targetAux?.type == 'element-group' - ) { - positionProperties.zIndexCanvas = app.layout.canvas.zIndex; + // If it's an element, or element group, show canvas layer + if ( + targetAux?.type == 'element' || + targetAux?.type == 'element-group' + ) { + positionProperties.zIndexCanvas = app.layout.canvas.zIndex; - (targetAux?.type == 'element') && - (positionProperties.showElementLayer = true); + (targetAux?.type == 'element') && + (positionProperties.showElementLayer = true); - (targetAux?.type == 'element-group') && - (positionProperties.showElementGroupLayer = true); - } + (targetAux?.type == 'element-group') && + (positionProperties.showElementGroupLayer = true); + } - self.DOMObject.find('#advancedTab').after( - positionTemplate( - Object.assign(positionProperties, {trans: propertiesPanelTrans}), - ), - ); + self.DOMObject.find('#advancedTab').after( + positionTemplate( + Object.assign(positionProperties, {trans: propertiesPanelTrans}), + ), + ); - // Hide make fullscreen button for element groups - if (isElementGroup) { - self.DOMObject.find('#positionTab #setFullScreen').hide(); - } + // Hide make fullscreen button for element groups + if (isElementGroup) { + self.DOMObject.find('#positionTab #setFullScreen').hide(); + } - // Check if we should show the bring to view button - const checkBringToView = function(pos) { - const notInView = ( - pos.left > lD.layout.width || - (pos.left + pos.width) < 0 || - pos.top > lD.layout.height || - (pos.top + pos.height) < 0 - ); - self.DOMObject.find('#positionTab #bringToView') - .toggleClass('d-none', !notInView); - }; - checkBringToView(positionProperties); + // Check if we should show the bring to view button + const checkBringToView = function(pos) { + const notInView = ( + pos.left > lD.layout.width || + (pos.left + pos.width) < 0 || + pos.top > lD.layout.height || + (pos.top + pos.height) < 0 + ); + self.DOMObject.find('#positionTab #bringToView') + .toggleClass('d-none', !notInView); + }; + checkBringToView(positionProperties); - // If we change any input, update the target position - self.DOMObject.find('#positionTab [name]').on( - 'change', _.debounce(function(ev) { - const form = $(ev.currentTarget).parents('#positionTab'); + // If we change any input, update the target position + self.DOMObject.find('#positionTab [name]').on( + 'change', _.debounce(function(ev) { + const form = $(ev.currentTarget).parents('#positionTab'); - const preventNegative = function($field) { - // Prevent layer to be negative - let fieldValue = Number($field.val()); - if (fieldValue && fieldValue < 0) { - fieldValue = 0; + const preventNegative = function($field) { + // Prevent layer to be negative + let fieldValue = Number($field.val()); + if (fieldValue && fieldValue < 0) { + fieldValue = 0; - // Set form field back to 0 - $field.val(0); - } + // Set form field back to 0 + $field.val(0); + } - // Return field value - return fieldValue; - }; + // Return field value + return fieldValue; + }; - // If we changed the canvas layer, save only the canvas region - if ( - $(ev.currentTarget).parents('.position-canvas-input').length > 0 - ) { - const canvasZIndexVal = preventNegative( - form.find('[name="zIndexCanvas"]'), - ); + // If we changed the canvas layer, save only the canvas region + if ( + $(ev.currentTarget).parents('.position-canvas-input').length > 0 + ) { + const canvasZIndexVal = preventNegative( + form.find('[name="zIndexCanvas"]'), + ); - // Save canvas region - app.layout.canvas.changeLayer(canvasZIndexVal); + // Save canvas region + app.layout.canvas.changeLayer(canvasZIndexVal); - // Change layer for the viewer object - app.viewer.DOMObject.find('.designer-region-canvas') - .css('zIndex', canvasZIndexVal); + // Change layer for the viewer object + app.viewer.DOMObject.find('.designer-region-canvas') + .css('zIndex', canvasZIndexVal); - // Update layer manager - app.viewer.layerManager.render(); + // Update layer manager + app.viewer.layerManager.render(); - // Don't save the rest of the form - return; - } + // Don't save the rest of the form + return; + } - const viewerScale = lD.viewer.containerObjectDimensions.scale; + const viewerScale = lD.viewer.containerObjectDimensions.scale; - // Prevent layer to be negative - const zIndexVal = preventNegative( - form.find('[name="zIndex"]'), - ); + // Prevent layer to be negative + const zIndexVal = preventNegative( + form.find('[name="zIndex"]'), + ); - if (targetAux == undefined) { - // Widget - const regionId = (target.type === 'region') ? - target.id : - target.parent.id; - const positions = { - width: form.find('[name="width"]').val(), - height: form.find('[name="height"]').val(), - top: form.find('[name="top"]').val(), - left: form.find('[name="left"]').val(), - zIndex: zIndexVal, - }; + if (targetAux == undefined) { + // Widget + const regionId = (target.type === 'region') ? + target.id : + target.parent.id; + const positions = { + width: form.find('[name="width"]').val(), + height: form.find('[name="height"]').val(), + top: form.find('[name="top"]').val(), + left: form.find('[name="left"]').val(), + zIndex: zIndexVal, + }; + + lD.layout.regions[regionId].transform(positions, true); + + // Check bring to view button + checkBringToView(positions); + + lD.viewer.updateRegion(lD.layout.regions[regionId]); + + // Update moveable + lD.viewer.updateMoveable(); + } else if (targetAux?.type == 'element') { + // Element + const $targetElement = $('#' + targetAux.elementId); + + // Move element + $targetElement.css({ + width: form.find('[name="width"]').val() * viewerScale, + height: form.find('[name="height"]').val() * viewerScale, + top: form.find('[name="top"]').val() * viewerScale, + left: form.find('[name="left"]').val() * viewerScale, + zIndex: zIndexVal, + }); - lD.layout.regions[regionId].transform(positions, true); + // Check bring to view button + checkBringToView({ + width: form.find('[name="width"]').val(), + height: form.find('[name="height"]').val(), + top: form.find('[name="top"]').val(), + left: form.find('[name="left"]').val(), + }); - // Check bring to view button - checkBringToView(positions); + // Rotate element + if (form.find('[name="rotation"]').val() != undefined) { + $targetElement.css('transform', 'rotate(' + + form.find('[name="rotation"]').val() + + 'deg)'); + lD.viewer.moveable.updateRect(); + } - lD.viewer.updateRegion(lD.layout.regions[regionId]); + // Save layer + targetAux.layer = zIndexVal; - // Update moveable - lD.viewer.updateMoveable(); - } else if (targetAux?.type == 'element') { - // Element - const $targetElement = $('#' + targetAux.elementId); - - // Move element - $targetElement.css({ - width: form.find('[name="width"]').val() * viewerScale, - height: form.find('[name="height"]').val() * viewerScale, - top: form.find('[name="top"]').val() * viewerScale, - left: form.find('[name="left"]').val() * viewerScale, - zIndex: zIndexVal, - }); + // Recalculate group dimensions + if (targetAux.groupId) { + lD.viewer.saveElementGroupProperties( + lD.viewer.DOMObject.find('#' + targetAux.groupId), + true, + false, + ); + } else { + // Save properties + lD.viewer.saveElementProperties($targetElement, true); + } - // Check bring to view button - checkBringToView({ - width: form.find('[name="width"]').val(), - height: form.find('[name="height"]').val(), - top: form.find('[name="top"]').val(), - left: form.find('[name="left"]').val(), - }); + // Update element + lD.viewer.updateElement(targetAux, true); + + // Update moveable + lD.viewer.updateMoveable(); + } else if (targetAux?.type == 'element-group') { + // Element group + const $targetElementGroup = $('#' + targetAux.id); + + // Move element group + $targetElementGroup.css({ + width: form.find('[name="width"]').val() * viewerScale, + height: form.find('[name="height"]').val() * viewerScale, + top: form.find('[name="top"]').val() * viewerScale, + left: form.find('[name="left"]').val() * viewerScale, + zIndex: zIndexVal, + }); - // Rotate element - if (form.find('[name="rotation"]').val() != undefined) { - $targetElement.css('transform', 'rotate(' + - form.find('[name="rotation"]').val() + - 'deg)'); - lD.viewer.moveable.updateRect(); - } + // Save layer + targetAux.layer = zIndexVal; - // Save layer - targetAux.layer = zIndexVal; + // Check bring to view button + checkBringToView({ + width: form.find('[name="width"]').val(), + height: form.find('[name="height"]').val(), + top: form.find('[name="top"]').val(), + left: form.find('[name="left"]').val(), + }); - // Recalculate group dimensions - if (targetAux.groupId) { + // Scale group + // Update element dimension properties + targetAux.transform({ + width: parseFloat( + form.find('[name="width"]').val(), + ), + height: parseFloat( + form.find('[name="height"]').val(), + ), + }, false); + lD.viewer.updateElementGroup(targetAux); + + // Save properties lD.viewer.saveElementGroupProperties( - lD.viewer.DOMObject.find('#' + targetAux.groupId), + $targetElementGroup, + true, true, - false, ); - } else { - // Save properties - lD.viewer.saveElementProperties($targetElement, true); + + // Update moveable + lD.viewer.updateMoveable(); } - // Update element - lD.viewer.updateElement(targetAux, true); + // Update layer manager + app.viewer.layerManager.render(); + }, 200)); + + // Handle set fullscreen button + self.DOMObject.find('#positionTab #setFullScreen').off().on( + 'click', + function(ev) { + const form = $(ev.currentTarget).parents('#positionTab'); + const viewerScale = lD.viewer.containerObjectDimensions.scale; + + if (targetAux == undefined) { + // Widget + const regionId = target.parent.id; + + lD.layout.regions[regionId].transform({ + width: lD.layout.width, + height: lD.layout.height, + top: 0, + left: 0, + }, true); + + lD.viewer.updateRegion(lD.layout.regions[regionId], true); + } else if (targetAux?.type == 'element') { + // Element + const $targetElement = $('#' + targetAux.elementId); + + // Move element + $targetElement.css({ + width: lD.layout.width * viewerScale, + height: lD.layout.height * viewerScale, + top: 0, + left: 0, + }); + + // Save properties + lD.viewer.saveElementProperties($targetElement, true); - // Update moveable - lD.viewer.updateMoveable(); - } else if (targetAux?.type == 'element-group') { - // Element group - const $targetElementGroup = $('#' + targetAux.id); - - // Move element group - $targetElementGroup.css({ - width: form.find('[name="width"]').val() * viewerScale, - height: form.find('[name="height"]').val() * viewerScale, - top: form.find('[name="top"]').val() * viewerScale, - left: form.find('[name="left"]').val() * viewerScale, - zIndex: zIndexVal, - }); + // Update element + lD.viewer.updateElement(targetAux, true); + } - // Save layer - targetAux.layer = zIndexVal; + // Change position tab values + form.find('[name="width"]').val(lD.layout.width); + form.find('[name="height"]').val(lD.layout.height); + form.find('[name="top"]').val(0); + form.find('[name="left"]').val(0); // Check bring to view button checkBringToView({ - width: form.find('[name="width"]').val(), - height: form.find('[name="height"]').val(), - top: form.find('[name="top"]').val(), - left: form.find('[name="left"]').val(), - }); - - // Scale group - // Update element dimension properties - targetAux.transform({ - width: parseFloat( - form.find('[name="width"]').val(), - ), - height: parseFloat( - form.find('[name="height"]').val(), - ), - }, false); - lD.viewer.updateElementGroup(targetAux); - - // Save properties - lD.viewer.saveElementGroupProperties( - $targetElementGroup, - true, - true, - ); - - // Update moveable - lD.viewer.updateMoveable(); - } - - // Update layer manager - app.viewer.layerManager.render(); - }, 200)); - - // Handle set fullscreen button - self.DOMObject.find('#positionTab #setFullScreen').off().on( - 'click', - function(ev) { - const form = $(ev.currentTarget).parents('#positionTab'); - const viewerScale = lD.viewer.containerObjectDimensions.scale; - - if (targetAux == undefined) { - // Widget - const regionId = target.parent.id; - - lD.layout.regions[regionId].transform({ width: lD.layout.width, height: lD.layout.height, top: 0, left: 0, - }, true); - - lD.viewer.updateRegion(lD.layout.regions[regionId], true); - } else if (targetAux?.type == 'element') { - // Element - const $targetElement = $('#' + targetAux.elementId); - - // Move element - $targetElement.css({ - width: lD.layout.width * viewerScale, - height: lD.layout.height * viewerScale, - top: 0, - left: 0, }); - // Save properties - lD.viewer.saveElementProperties($targetElement, true); - - // Update element - lD.viewer.updateElement(targetAux, true); - } - - // Change position tab values - form.find('[name="width"]').val(lD.layout.width); - form.find('[name="height"]').val(lD.layout.height); - form.find('[name="top"]').val(0); - form.find('[name="left"]').val(0); - - // Check bring to view button - checkBringToView({ - width: lD.layout.width, - height: lD.layout.height, - top: 0, - left: 0, + // Update moveable + lD.viewer.updateMoveable(); }); - // Update moveable - lD.viewer.updateMoveable(); - }); - - // Handle bring to view button - self.DOMObject.find('#positionTab #bringToView').off().on( - 'click', - function(ev) { - const form = $(ev.currentTarget).parents('#positionTab'); - const viewerScale = lD.viewer.containerObjectDimensions.scale; - - // Get position to fix the item being outside of the layout - const calculateNewPosition = function(positions) { - if (Number(positions.left) > app.layout.width) { - positions.left = app.layout.width - positions.width; - } - if (Number(positions.left) + positions.width < 0) { - positions.left = 0; - } - if (Number(positions.top) > app.layout.height) { - positions.top = app.layout.height - positions.height; - } - if (Number(positions.top) + Number(positions.height) < 0) { - positions.top = 0; - } - return positions; - }; + // Handle bring to view button + self.DOMObject.find('#positionTab #bringToView').off().on( + 'click', + function(ev) { + const form = $(ev.currentTarget).parents('#positionTab'); + const viewerScale = lD.viewer.containerObjectDimensions.scale; + + // Get position to fix the item being outside of the layout + const calculateNewPosition = function(positions) { + if (Number(positions.left) > app.layout.width) { + positions.left = app.layout.width - positions.width; + } + if (Number(positions.left) + positions.width < 0) { + positions.left = 0; + } + if (Number(positions.top) > app.layout.height) { + positions.top = app.layout.height - positions.height; + } + if (Number(positions.top) + Number(positions.height) < 0) { + positions.top = 0; + } + return positions; + }; - let newPosition = {}; + let newPosition = {}; - if (targetAux == undefined) { - // Widget - const regionId = target.parent.id; + if (targetAux == undefined) { + // Widget + const regionId = target.parent.id; - newPosition = - calculateNewPosition(lD.layout.regions[regionId].dimensions); + newPosition = + calculateNewPosition(lD.layout.regions[regionId].dimensions); - lD.layout.regions[regionId].transform( - newPosition, - true, - ); + lD.layout.regions[regionId].transform( + newPosition, + true, + ); - lD.viewer.updateRegion(lD.layout.regions[regionId], true); - } else if (targetAux?.type == 'element') { - // Element - const $targetElement = $('#' + targetAux.elementId); - - newPosition = - calculateNewPosition({ - width: targetAux.width, - height: targetAux.height, - top: targetAux.top, - left: targetAux.left, + lD.viewer.updateRegion(lD.layout.regions[regionId], true); + } else if (targetAux?.type == 'element') { + // Element + const $targetElement = $('#' + targetAux.elementId); + + newPosition = + calculateNewPosition({ + width: targetAux.width, + height: targetAux.height, + top: targetAux.top, + left: targetAux.left, + }); + + // Move element + $targetElement.css({ + width: newPosition.width * viewerScale, + height: newPosition.height * viewerScale, + top: newPosition.top * viewerScale, + left: newPosition.left * viewerScale, }); - // Move element - $targetElement.css({ - width: newPosition.width * viewerScale, - height: newPosition.height * viewerScale, - top: newPosition.top * viewerScale, - left: newPosition.left * viewerScale, - }); - - // Save properties - lD.viewer.saveElementProperties($targetElement, true); + // Save properties + lD.viewer.saveElementProperties($targetElement, true); - // Update element - lD.viewer.updateElement(targetAux, true); - } else if (targetAux?.type == 'element-group') { - const $targetElementGroup = $('#' + targetAux.id); + // Update element + lD.viewer.updateElement(targetAux, true); + } else if (targetAux?.type == 'element-group') { + const $targetElementGroup = $('#' + targetAux.id); + + newPosition = + calculateNewPosition({ + width: targetAux.width, + height: targetAux.height, + top: targetAux.top, + left: targetAux.left, + }); + + // Move element group + $targetElementGroup.css({ + width: newPosition.width * viewerScale, + height: newPosition.height * viewerScale, + top: newPosition.top * viewerScale, + left: newPosition.left * viewerScale, + }); - newPosition = - calculateNewPosition({ - width: targetAux.width, - height: targetAux.height, - top: targetAux.top, - left: targetAux.left, + // Scale group + // Update element dimension properties + targetAux.transform({ + width: newPosition.width, + height: newPosition.height, + top: newPosition.top, + left: newPosition.left, }); - // Move element group - $targetElementGroup.css({ - width: newPosition.width * viewerScale, - height: newPosition.height * viewerScale, - top: newPosition.top * viewerScale, - left: newPosition.left * viewerScale, - }); + lD.viewer.updateElementGroup(targetAux); - // Scale group - // Update element dimension properties - targetAux.transform({ - width: newPosition.width, - height: newPosition.height, - top: newPosition.top, - left: newPosition.left, - }); + // Save properties + lD.viewer.saveElementGroupProperties( + $targetElementGroup, + true, + true, + ); - lD.viewer.updateElementGroup(targetAux); + // Update moveable + lD.viewer.updateMoveable(); + } - // Save properties - lD.viewer.saveElementGroupProperties( - $targetElementGroup, - true, - true, - ); + // Change position tab values + form.find('[name="width"]').val(newPosition.width); + form.find('[name="height"]').val(newPosition.height); + form.find('[name="top"]').val(newPosition.top); + form.find('[name="left"]').val(newPosition.left); // Update moveable lD.viewer.updateMoveable(); - } + }); + }; - // Change position tab values - form.find('[name="width"]').val(newPosition.width); - form.find('[name="height"]').val(newPosition.height); - form.find('[name="top"]').val(newPosition.top); - form.find('[name="left"]').val(newPosition.left); + // If it's an element, get properties, first to update it + // and only then call render position tab + if (targetAux?.type === 'element') { + targetAux.getProperties().then(function() { + renderPositionTab(); - // Update moveable - lD.viewer.updateMoveable(); + // Handle replacements for element + const data = { + layout: app.layout, + }; + forms.handleFormReplacements(self.DOMObject.find('form'), data); }); + } else { + renderPositionTab(); + } } // For media widget, add replacement button diff --git a/ui/src/layout-editor/viewer.js b/ui/src/layout-editor/viewer.js index f0a8619123..3a3bcd7e5f 100644 --- a/ui/src/layout-editor/viewer.js +++ b/ui/src/layout-editor/viewer.js @@ -1440,22 +1440,30 @@ Viewer.prototype.updateElement = function( ) { const $container = lD.viewer.DOMObject.find(`#${element.elementId}`); + // Get real elements from the structure + const realElement = + lD.getObjectByTypeAndId( + 'element', + element.elementId, + 'widget_' + element.regionId + '_' + element.widgetId, + ); + // Calculate scaled dimensions - element.scaledDimensions = { - height: element.height * lD.viewer.containerObjectDimensions.scale, - left: element.left * lD.viewer.containerObjectDimensions.scale, - top: element.top * lD.viewer.containerObjectDimensions.scale, - width: element.width * lD.viewer.containerObjectDimensions.scale, + realElement.scaledDimensions = { + height: realElement.height * lD.viewer.containerObjectDimensions.scale, + left: realElement.left * lD.viewer.containerObjectDimensions.scale, + top: realElement.top * lD.viewer.containerObjectDimensions.scale, + width: realElement.width * lD.viewer.containerObjectDimensions.scale, }; // Update element index $container.css({ - 'z-index': element.layer, + 'z-index': realElement.layer, }); // Update element content lD.viewer.renderElementContent( - element, + realElement, ); }; @@ -2489,6 +2497,7 @@ Viewer.prototype.initMoveable = function() { // Apply transformation to the element const transformSplit = (target.style.transform).split(/[(),]+/); let hasTranslate = false; + let hasRotate = false; // If the transform has translate if (target.style.transform.search('translate') != -1) { @@ -2507,6 +2516,10 @@ Viewer.prototype.initMoveable = function() { transformSplit[4] : transformSplit[1]; + if (rotateValue != '0deg') { + hasRotate = true; + } + target.style.transform = `rotate(${rotateValue})`; } else { target.style.transform = ''; @@ -2514,7 +2527,8 @@ Viewer.prototype.initMoveable = function() { // If snap to borders is active, prevent negative values // Or snap to border if <1px delta - if (self.moveableOptions.snapToBorders) { + // only works for no rotation + if (self.moveableOptions.snapToBorders && !hasRotate) { let left = Number(target.style.left.split('px')[0]); let top = Number(target.style.top.split('px')[0]); let width = Number(target.style.width.split('px')[0]); From 6d46c0cec04d23b5e1cfc9ba8a489ac72c17dca8 Mon Sep 17 00:00:00 2001 From: Mae Grace Baybay <87640602+mgbaybay@users.noreply.github.com> Date: Tue, 6 Aug 2024 15:04:48 +0800 Subject: [PATCH 167/203] Reporting: Add Availability and Grouping Option in Time Connected Summary (#2659) --- lib/Report/TimeDisconnectedSummary.php | 107 +++++++++++++++++- .../timedisconnectedsummary-report-form.twig | 39 ++++++- 2 files changed, 139 insertions(+), 7 deletions(-) diff --git a/lib/Report/TimeDisconnectedSummary.php b/lib/Report/TimeDisconnectedSummary.php index ef3fe494a5..78d6ce0c6f 100644 --- a/lib/Report/TimeDisconnectedSummary.php +++ b/lib/Report/TimeDisconnectedSummary.php @@ -198,6 +198,7 @@ public function getResults(SanitizerInterface $sanitizedParams) $tags = $sanitizedParams->getString('tags'); $onlyLoggedIn = $sanitizedParams->getCheckbox('onlyLoggedIn') == 1; + $groupBy = $sanitizedParams->getString('groupBy'); $currentDate = Carbon::now()->startOfDay(); @@ -340,6 +341,10 @@ public function getResults(SanitizerInterface $sanitizedParams) $displayBody = 'FROM `display` '; + if ($groupBy === 'displayGroup') { + $displaySelect .= ', displaydg.displayGroup, displaydg.displayGroupId '; + } + if ($tags != '') { $displayBody .= 'INNER JOIN `lkdisplaydg` ON lkdisplaydg.DisplayID = display.displayid @@ -347,12 +352,24 @@ public function getResults(SanitizerInterface $sanitizedParams) ON displaygroup.displaygroupId = lkdisplaydg.displaygroupId AND `displaygroup`.isDisplaySpecific = 1 '; } + + // Grouping Option + if ($groupBy === 'displayGroup') { + $displayBody .= 'INNER JOIN `lkdisplaydg` AS linkdg + ON linkdg.DisplayID = display.displayid + INNER JOIN `displaygroup` AS displaydg + ON displaydg.displaygroupId = linkdg.displaygroupId + AND `displaydg`.isDisplaySpecific = 0 '; + } + $displayBody .= 'WHERE 1 = 1 '; if (count($displayIds) > 0) { $displayBody .= 'AND display.displayId IN (' . implode(',', $displayIds) . ') '; } + $tagParams = []; + if ($tags != '') { if (trim($tags) === '--no-tag') { $displayBody .= ' AND `displaygroup`.displaygroupId NOT IN ( @@ -383,9 +400,9 @@ public function getResults(SanitizerInterface $sanitizedParams) } if ($operator === '=') { - $params['tags' . $i] = $tag; + $tagParams['tags' . $i] = $tag; } else { - $params['tags' . $i] = '%' . $tag . '%'; + $tagParams['tags' . $i] = '%' . $tag . '%'; } } @@ -401,6 +418,14 @@ public function getResults(SanitizerInterface $sanitizedParams) GROUP BY display.display, display.displayId '; + if ($tags != '') { + $displayBody .= ', displaygroup.displayGroupId '; + } + + if ($groupBy === 'displayGroup') { + $displayBody .= ', displaydg.displayGroupId '; + } + // Sorting? $sortOrder = $this->gridRenderSort($sanitizedParams); @@ -414,7 +439,7 @@ public function getResults(SanitizerInterface $sanitizedParams) $rows = []; // Retrieve the disconnected/connected time from the $disconnectedDisplays array into displays - foreach ($this->store->select($displaySql, []) as $displayRow) { + foreach ($this->store->select($displaySql, $tagParams) as $displayRow) { $sanitizedDisplayRow = $this->sanitizer->getSanitizer($displayRow); $entry = []; $displayId = $sanitizedDisplayRow->getInt(('displayId')); @@ -423,6 +448,15 @@ public function getResults(SanitizerInterface $sanitizedParams) $entry['timeDisconnected'] = $disconnectedDisplays[$displayId]['timeDisconnected'] ?? 0 ; $entry['timeConnected'] = $disconnectedDisplays[$displayId]['timeConnected'] ?? round(($toDt->format('U') - $fromDt->format('U')) / $divisor, 2); $entry['postUnits'] = $postUnits; + $entry['displayGroupId'] = $sanitizedDisplayRow->getInt(('displayGroupId')); + $entry['displayGroup'] = $sanitizedDisplayRow->getString(('displayGroup')); + $entry['avgTimeDisconnected'] = 0; + $entry['avgTimeConnected'] = 0; + $entry['availabilityPercentage'] = $this->getAvailabilityPercentage( + $entry['timeConnected'], + $entry['timeDisconnected'] + ) . '%'; + $rows[] = $entry; } @@ -435,10 +469,16 @@ public function getResults(SanitizerInterface $sanitizedParams) $availabilityLabels = []; $postUnits = ''; + if ($groupBy === 'displayGroup') { + $rows = $this->getByDisplayGroup($rows, $sanitizedParams->getIntArray('displayGroupId', ['default' => []])); + } + foreach ($rows as $row) { $availabilityData[] = $row['timeDisconnected']; $availabilityDataConnected[] = $row['timeConnected']; - $availabilityLabels[] = $row['display']; + $availabilityLabels[] = ($groupBy === 'displayGroup') + ? $row['displayGroup'] + : $row['display']; $postUnits = $row['postUnits']; } @@ -500,4 +540,63 @@ public function getResults(SanitizerInterface $sanitizedParams) $chart ); } + + /** + * Get the Availability Percentage + * @param float $connectedTime + * @param float $disconnectedTime + * @return float + */ + private function getAvailabilityPercentage(float $connectedTime, float $disconnectedTime) : float + { + $connectedPercentage = $connectedTime/($connectedTime + $disconnectedTime ?: 1); + + return abs(round($connectedPercentage * 100, 2)); + } + + /** + * Get the accumulated value by display groups + * @param array $rows + * @param array $displayGroupIds + * @return array + */ + private function getByDisplayGroup(array $rows, array $displayGroupIds = []) : array + { + $data = []; + $displayGroups = []; + + // Get the accumulated values by displayGroupId + foreach ($rows as $row) { + $displayGroupId = $row['displayGroupId']; + + if (isset($displayGroups[$displayGroupId])) { + $displayGroups[$displayGroupId]['timeDisconnected'] += $row['timeDisconnected']; + $displayGroups[$displayGroupId]['timeConnected'] += $row['timeConnected']; + $displayGroups[$displayGroupId]['count'] += 1; + } else { + $row['count'] = 1; + $displayGroups[$displayGroupId] = $row; + } + } + + // Get all display groups or selected display groups only + foreach ($displayGroups as $displayGroup) { + if (!$displayGroupIds || in_array($displayGroup['displayGroupId'], $displayGroupIds)) { + $displayGroup['timeConnected'] = round($displayGroup['timeConnected'], 2); + $displayGroup['timeDisconnected'] = round($displayGroup['timeDisconnected'], 2); + $displayGroup['availabilityPercentage'] = $this->getAvailabilityPercentage( + $displayGroup['timeConnected'], + $displayGroup['timeDisconnected'] + ) . '%'; + + // Calculate the average values + $displayGroup['avgTimeConnected'] = round($displayGroup['timeConnected'] / $displayGroup['count'], 2); + $displayGroup['avgTimeDisconnected'] = round($displayGroup['timeDisconnected'] / $displayGroup['count'], 2); + + $data[] = $displayGroup; + } + } + + return $data; + } } diff --git a/reports/timedisconnectedsummary-report-form.twig b/reports/timedisconnectedsummary-report-form.twig index 0b9510e21d..c667451692 100644 --- a/reports/timedisconnectedsummary-report-form.twig +++ b/reports/timedisconnectedsummary-report-form.twig @@ -50,6 +50,13 @@ {% set title %}{% trans "To Date" %}{% endset %} {{ inline.date("availabilityToDt", title, defaults.toDate, "", "", "", "") }} + {% set title %}{% trans "Group By" %}{% endset %} + {% set options = [ + { id: "display", name: "Display"|trans }, + { id: "displayGroup", name: "Display Group"|trans }, + ] %} + {{ inline.dropdown("groupBy", "single", title, "", options, "id", "name", "") }} + {% set title %}{% trans "Display" %}{% endset %} {% set attributes = [ { name: "data-width", value: "200px" }, @@ -136,10 +143,14 @@ {% trans "Display ID" %} {% trans "Display" %} + {% trans "Display Group ID" %} + {% trans "Display Group" %} {% trans "Time Disconnected" %} {% trans "Time Connected" %} + {% trans "Average Time Disconnected" %} + {% trans "Average Time Connected" %} + {% trans "Availability" %} {% trans "Units" %} - @@ -198,10 +209,26 @@ columns: [ {"data": "displayId"}, {"data": "display"}, + {"data": "displayGroupId"}, + {"data": "displayGroup"}, {"data": "timeDisconnected", "sortable": false}, {"data": "timeConnected", "sortable": false}, - {"data": "postUnits", "sortable": false} - ] + {"data": "avgTimeDisconnected", "sortable": false}, + {"data": "avgTimeConnected", "sortable": false}, + {"data": "availabilityPercentage", "sortable": false}, + {"data": "postUnits", "sortable": false}, + ], + drawCallback: function () { + if ($('select[name="groupBy"]').val() === 'displayGroup') { + // Hide 'Display ID and Display' columns + $(this.api().columns([0, 1]).visible(false)); + $(this.api().columns([2, 3, 6, 7]).visible(true)); + } else { + // Hide 'Display Group ID, Display Group, Avg Time Connected/Disconnected' columns + $(this.api().columns([2, 3, 6, 7]).visible(false)); + $(this.api().columns([0, 1]).visible(true)); + } + }, }); // Get Data @@ -278,9 +305,12 @@ if (displayId) { $('select[name="displayGroupId[]"] option').remove(); $('select[name="displayGroupId[]"]').next(".select2-container").parent().hide(); + $('select[name="groupBy[]"] option').remove(); + $('select[name="groupBy"]').parent().hide(); } else { $('#displayId option').remove(); $('select[name="displayGroupId[]"]').next(".select2-container").parent().show(); + $('select[name="groupBy"]').parent().show(); } }); @@ -298,9 +328,12 @@ if (displayId) { $('select[name="displayGroupId[]"] option').remove(); $('select[name="displayGroupId[]"]').next(".select2-container").parent().parent().hide(); + $('select[name="groupBy[]"] option').remove(); + $('select[name="groupBy"]').parent().hide(); } else { $('#reportScheduleAddForm #displayId option').remove(); $('select[name="displayGroupId[]"]').next(".select2-container").parent().parent().show(); + $('select[name="groupBy"]').parent().show(); } }); } From bf272eb078449f8a65cc898b50ee1a34312fa9bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mauro=20Ferr=C3=A3o?= Date: Tue, 6 Aug 2024 13:51:13 +0100 Subject: [PATCH 168/203] Module Settings: Translation from editor throwing error (#2685) relates to xibosignageltd/xibo-private#828 --- ui/src/core/forms.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ui/src/core/forms.js b/ui/src/core/forms.js index 4e5424cfdc..23ad3c10d0 100644 --- a/ui/src/core/forms.js +++ b/ui/src/core/forms.js @@ -248,7 +248,15 @@ window.forms = { if (templates.forms.hasOwnProperty(property.type)) { // New field const $newField = $(templates.forms[property.type]( - Object.assign({}, property, {trans: propertiesPanelTrans}), + Object.assign( + {}, + property, + { + trans: (typeof propertiesPanelTrans === 'undefined') ? + {} : + propertiesPanelTrans, + }, + ), )); // Target to append to From f77dc2b0f9f9365e9b7065b8e799bdb890fd99fd Mon Sep 17 00:00:00 2001 From: Ruben Pingol <128448242+rubenpingol-xibo@users.noreply.github.com> Date: Wed, 7 Aug 2024 10:16:23 +0800 Subject: [PATCH 169/203] XLR: Fixing preview layering (#2682) relates to xibosignageltd/xibo-private#812 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index e62e25ccb0..9bedd2c169 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12006,8 +12006,8 @@ "from": "git+https://github.com/xibosignage/xibo-interactive-control.git#44ad744a5a2a0af9e7fcdb828dbdbeff45cc40f0" }, "xibo-layout-renderer": { - "version": "git+https://github.com/xibosignage/xibo-layout-renderer.git#0fe31c109aeeafe6aa37bd4a1ff449a29a315b55", - "from": "git+https://github.com/xibosignage/xibo-layout-renderer.git#0fe31c109aeeafe6aa37bd4a1ff449a29a315b55" + "version": "git+https://github.com/xibosignage/xibo-layout-renderer.git#d867c24f59ad7462cf716caea6ee67bafe32474f", + "from": "git+https://github.com/xibosignage/xibo-layout-renderer.git#d867c24f59ad7462cf716caea6ee67bafe32474f" }, "xtend": { "version": "4.0.2", diff --git a/package.json b/package.json index 367f1c066d..5a7dee418d 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,6 @@ "toastr": "~2.1.4", "underscore": "^1.12.1", "xibo-interactive-control": "git+https://github.com/xibosignage/xibo-interactive-control.git#44ad744a5a2a0af9e7fcdb828dbdbeff45cc40f0", - "xibo-layout-renderer": "git+https://github.com/xibosignage/xibo-layout-renderer.git#0fe31c109aeeafe6aa37bd4a1ff449a29a315b55" + "xibo-layout-renderer": "git+https://github.com/xibosignage/xibo-layout-renderer.git#d867c24f59ad7462cf716caea6ee67bafe32474f" } } From 94fadcc013963c84614d134cb70a25c97315bd63 Mon Sep 17 00:00:00 2001 From: Nadz Mangandog <61860661+nadzpogi@users.noreply.github.com> Date: Wed, 7 Aug 2024 11:39:44 +0800 Subject: [PATCH 170/203] Enhancements for DataSet column configuration checks (#2665) relates to xibosignage/xibo#3458 * Added message in the Edit DataSet Form to inform users if it has no columns configured. * Added message in the Edit DataSet Form to inform users if it has no remote columns configured for Remote DataSets. * Prevented importing if the DataSet has no columns configured. * Prevented remote importing if the DataSet has no remote columns configured. --- lib/Controller/DataSet.php | 3 +++ lib/Helper/DataSetUploadHandler.php | 15 ++++++++++++++- lib/XTR/RemoteDataSetFetchTask.php | 14 ++++++++++++++ views/dataset-form-edit.twig | 12 ++++++++++++ 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/lib/Controller/DataSet.php b/lib/Controller/DataSet.php index d5d406dde5..960e472171 100644 --- a/lib/Controller/DataSet.php +++ b/lib/Controller/DataSet.php @@ -729,6 +729,9 @@ public function editForm(Request $request, Response $response, $id) // Retrieve data sources from the event $dataConnectorSources = $event->getDataConnectorSources(); + // retrieve the columns of the selected dataset + $dataSet->getColumn(); + // Set the form $this->getState()->template = 'dataset-form-edit'; $this->getState()->setData([ diff --git a/lib/Helper/DataSetUploadHandler.php b/lib/Helper/DataSetUploadHandler.php index 0b5a2c8dfc..33b8e1ba43 100644 --- a/lib/Helper/DataSetUploadHandler.php +++ b/lib/Helper/DataSetUploadHandler.php @@ -62,6 +62,20 @@ protected function handleFormData($file, $index) throw new AccessDeniedException(); } + // Get all columns + $columns = $dataSet->getColumn(); + + // Filter columns where dataSetColumnType is "Value" + $filteredColumns = array_filter($columns, function ($column) { + return $column->dataSetColumnTypeId == '1'; + }); + + // Check if there are any value columns defined in the dataset + if (count($filteredColumns) === 0) { + $controller->getLog()->error('Import failed: No value columns defined in the dataset.'); + throw new InvalidArgumentException(__('Import failed: No value columns defined in the dataset.')); + } + // We are allowed to edit - pull all required parameters from the request object $overwrite = $sanitizer->getCheckbox('overwrite'); $ignoreFirstRow = $sanitizer->getCheckbox('ignorefirstrow'); @@ -95,7 +109,6 @@ protected function handleFormData($file, $index) $firstRow = true; $i = 0; - $handle = fopen($controller->getConfig()->getSetting('LIBRARY_LOCATION') . 'temp/' . $fileName, 'r'); while (($data = fgetcsv($handle)) !== FALSE ) { $i++; diff --git a/lib/XTR/RemoteDataSetFetchTask.php b/lib/XTR/RemoteDataSetFetchTask.php index 816fbaa309..0bdd16e585 100644 --- a/lib/XTR/RemoteDataSetFetchTask.php +++ b/lib/XTR/RemoteDataSetFetchTask.php @@ -99,6 +99,20 @@ public function run() continue; } + // Get all columns + $columns = $dataSet->getColumn(); + + // Filter columns where dataSetColumnType is "Remote" + $filteredColumns = array_filter($columns, function ($column) { + return $column->dataSetColumnTypeId == '3'; + }); + + // Check if there are any remote columns defined in the dataset + if (count($filteredColumns) === 0) { + $this->log->info('Skipping dataSet ' . $dataSet->dataSetId . ': No remote columns defined in the dataset.'); + continue; + } + $this->log->debug('Comparing run time ' . $runTime . ' to next sync time ' . $dataSet->getNextSyncTime()); if ($runTime >= $dataSet->getNextSyncTime()) { diff --git a/views/dataset-form-edit.twig b/views/dataset-form-edit.twig index 5fc584cd66..9efc893586 100644 --- a/views/dataset-form-edit.twig +++ b/views/dataset-form-edit.twig @@ -94,6 +94,18 @@ {{ forms.dropdown("dataConnectorSource", "single", title, dataSet.dataConnectorSource, dataConnectorSources, "id", "name", helpText) }}
+ {% if dataSet.isRemote %} + {% set columnCount = dataSet.columns|filter(column => column.dataSetColumnTypeId == '3') %} + {% if columnCount|length == 0 %} + {{ forms.message("No remote columns have been configured for this dataset. Please configure your columns accordingly."|trans, "alert alert-warning") }} + {% endif %} + {% else %} + {% set columnCount = dataSet.columns|filter(column => column.dataSetColumnTypeId == '1') %} + {% if columnCount|length == 0 %} + {{ forms.message("No value columns have been configured for this dataset. Please configure your columns accordingly."|trans, "alert alert-warning") }} + {% endif %} + {% endif %} + {% if dataSet.isActive() %} {{ forms.message("This DataSet has been accessed or updated recently, which means the CMS will keep it active."|trans, "alert alert-success") }} {% endif %} From 7782d395ed662b02012c369cd3d914bb799fb4fe Mon Sep 17 00:00:00 2001 From: Nadz Mangandog <61860661+nadzpogi@users.noreply.github.com> Date: Wed, 7 Aug 2024 11:42:24 +0800 Subject: [PATCH 171/203] Fix: Unable to save Schedule Action event due to an error (#2680) relates to xibosignage/xibo#3460 --- lib/Controller/Schedule.php | 8 ++++---- ui/src/core/xibo-calendar.js | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/Controller/Schedule.php b/lib/Controller/Schedule.php index 655b448996..f59b3fe64e 100644 --- a/lib/Controller/Schedule.php +++ b/lib/Controller/Schedule.php @@ -1747,21 +1747,21 @@ public function edit(Request $request, Response $response, $id) if ($schedule->eventTypeId === \Xibo\Entity\Schedule::$ACTION_EVENT) { $schedule->actionType = $sanitizedParams->getString('actionType'); $schedule->actionTriggerCode = $sanitizedParams->getString('actionTriggerCode'); + $schedule->commandId = $sanitizedParams->getInt('commandId'); $schedule->actionLayoutCode = $sanitizedParams->getString('actionLayoutCode'); $schedule->campaignId = null; } else { $schedule->actionType = null; $schedule->actionTriggerCode = null; + $schedule->commandId = null; $schedule->actionLayoutCode = null; } - // collect commandId only on Command event - // null commandId otherwise + // collect commandId on Command event + // Retain existing commandId value otherwise if ($schedule->eventTypeId === \Xibo\Entity\Schedule::$COMMAND_EVENT) { $schedule->commandId = $sanitizedParams->getInt('commandId'); $schedule->campaignId = null; - } else { - $schedule->commandId = null; } // Set the parentCampaignId for campaign events diff --git a/ui/src/core/xibo-calendar.js b/ui/src/core/xibo-calendar.js index ef03b8cf1f..30e2bc4387 100644 --- a/ui/src/core/xibo-calendar.js +++ b/ui/src/core/xibo-calendar.js @@ -1264,6 +1264,8 @@ var processScheduleFormElements = function(el) { $('.layout-code-control').css('display', layoutCodeControl); $('.command-control').css('display', commandControlDisplay); + + break; case 'relativeTime' : if (!el.is(":visible")) { return; From 476c0d518d019a8a42350a6828cb96deddb285d1 Mon Sep 17 00:00:00 2001 From: Nadz Mangandog <61860661+nadzpogi@users.noreply.github.com> Date: Wed, 7 Aug 2024 11:45:37 +0800 Subject: [PATCH 172/203] Fix: Importing CSV with empty lines fails for remote datasets and file uploads (#2662) relates to xibosignage/xibo#3456 --- lib/Factory/DataSetFactory.php | 8 ++++++++ lib/Helper/DataSetUploadHandler.php | 16 ++++++++++------ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/lib/Factory/DataSetFactory.php b/lib/Factory/DataSetFactory.php index 2dca372d0b..5b702aaa6e 100644 --- a/lib/Factory/DataSetFactory.php +++ b/lib/Factory/DataSetFactory.php @@ -541,6 +541,14 @@ function ($v) use ($dataSet) { array_shift($array); } + // Filter out rows that are entirely empty + $array = array_filter($array, function($row) { + // Check if the row is empty (all elements are empty or null) + return array_filter($row, function($value) { + return !empty($value); + }); + }); + $result->entries = $array; $result->number = count($array); } diff --git a/lib/Helper/DataSetUploadHandler.php b/lib/Helper/DataSetUploadHandler.php index 33b8e1ba43..7f1eec68c8 100644 --- a/lib/Helper/DataSetUploadHandler.php +++ b/lib/Helper/DataSetUploadHandler.php @@ -104,15 +104,22 @@ protected function handleFormData($file, $index) if ($overwrite == 1) $dataSet->deleteData(); - // Load the file - ini_set('auto_detect_line_endings', true); - $firstRow = true; $i = 0; $handle = fopen($controller->getConfig()->getSetting('LIBRARY_LOCATION') . 'temp/' . $fileName, 'r'); while (($data = fgetcsv($handle)) !== FALSE ) { $i++; + // remove any elements that doesn't contain any value from the array + $filteredData = array_filter($data, function($value) { + return !empty($value); + }); + + // Skip empty lines without any delimiters or data + if (empty($filteredData)) { + continue; + } + $row = []; // The CSV file might have headings, so ignore the first row. @@ -150,9 +157,6 @@ protected function handleFormData($file, $index) // Close the file fclose($handle); - // Change the auto detect setting back - ini_set('auto_detect_line_endings', false); - // TODO: update list content definitions // Save the dataSet From f3160bdb404dc73ea7c4633b50aa30fa161f4489 Mon Sep 17 00:00:00 2001 From: Peter Date: Wed, 7 Aug 2024 08:06:46 +0100 Subject: [PATCH 173/203] Reports : API Requests new report type, various adjustments. (#2679) xibosignage/xibo#3362 --- lib/Dependencies/Factories.php | 5 ++ lib/Entity/ApplicationRequest.php | 94 ++++++++++++++++++++++ lib/Factory/ApplicationRequestsFactory.php | 40 +++++++++ lib/Middleware/ApiAuthorization.php | 6 +- lib/Report/ApiRequests.php | 65 ++++++++++++++- reports/apirequests-email-template.twig | 23 +++++- reports/apirequests-report-form.twig | 82 +++++++++++++++++-- reports/apirequests-report-preview.twig | 48 ++++++++++- reports/apirequests-schedule-form-add.twig | 1 + 9 files changed, 350 insertions(+), 14 deletions(-) create mode 100644 lib/Entity/ApplicationRequest.php create mode 100644 lib/Factory/ApplicationRequestsFactory.php diff --git a/lib/Dependencies/Factories.php b/lib/Dependencies/Factories.php index 25b36296f7..5f8ba457aa 100644 --- a/lib/Dependencies/Factories.php +++ b/lib/Dependencies/Factories.php @@ -43,6 +43,11 @@ public static function registerFactoriesWithDi() $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService')); return $repository; }, + 'apiRequestsFactory' => function (ContainerInterface $c) { + $repository = new \Xibo\Factory\ApplicationRequestsFactory(); + $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService')); + return $repository; + }, 'applicationFactory' => function (ContainerInterface $c) { $repository = new \Xibo\Factory\ApplicationFactory( $c->get('user'), diff --git a/lib/Entity/ApplicationRequest.php b/lib/Entity/ApplicationRequest.php new file mode 100644 index 0000000000..4406fa74be --- /dev/null +++ b/lib/Entity/ApplicationRequest.php @@ -0,0 +1,94 @@ +. + */ + +namespace Xibo\Entity; + +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Xibo\Service\LogServiceInterface; +use Xibo\Storage\StorageServiceInterface; + +class ApplicationRequest implements \JsonSerializable +{ + use EntityTrait; + + /** + * @SWG\Property(description="The request ID") + * @var int + */ + public $requestId; + + /** + * @SWG\Property(description="The user ID") + * @var int + */ + public $userId; + + /** + * @SWG\Property(description="The application ID") + * @var string + */ + public $applicationId; + + /** + * @SWG\Property(description="The request route") + * @var string + */ + public $url; + + /** + * @SWG\Property(description="The request method") + * @var string + */ + public $method; + + /** + * @SWG\Property(description="The request start time") + * @var string + */ + public $startTime; + + /** + * @SWG\Property(description="The request end time") + * @var string + */ + public $endTime; + + /** + * @SWG\Property(description="The request duration") + * @var int + */ + public $duration; + + /** + * Entity constructor. + * @param StorageServiceInterface $store + * @param LogServiceInterface $log + * @param EventDispatcherInterface $dispatcher + */ + public function __construct( + StorageServiceInterface $store, + LogServiceInterface $log, + EventDispatcherInterface $dispatcher + ) { + $this->setCommonDependencies($store, $log, $dispatcher); + } +} diff --git a/lib/Factory/ApplicationRequestsFactory.php b/lib/Factory/ApplicationRequestsFactory.php new file mode 100644 index 0000000000..2054168d7f --- /dev/null +++ b/lib/Factory/ApplicationRequestsFactory.php @@ -0,0 +1,40 @@ +. + */ + +namespace Xibo\Factory; + +use Xibo\Entity\ApplicationRequest; + +/** + * Class ApplicationRequestsFactory + * @package Xibo\Factory + */ +class ApplicationRequestsFactory extends BaseFactory +{ + /** + * @return ApplicationRequest + */ + public function createEmpty(): ApplicationRequest + { + return new ApplicationRequest($this->getStore(), $this->getLog(), $this->getDispatcher()); + } +} diff --git a/lib/Middleware/ApiAuthorization.php b/lib/Middleware/ApiAuthorization.php index b322116c1e..075c6ba839 100644 --- a/lib/Middleware/ApiAuthorization.php +++ b/lib/Middleware/ApiAuthorization.php @@ -192,12 +192,14 @@ public function process(Request $request, RequestHandler $handler): Response ', [ 'userId' => $user->userId, 'applicationId' => $validatedRequest->getAttribute('oauth_client_id'), - 'url' => $resource, + 'url' => htmlspecialchars($request->getUri()->getPath()), 'method' => $request->getMethod(), 'startTime' => Carbon::now(), 'endTime' => Carbon::now(), 'duration' => 0 - ]); + ], 'api_requests_history'); + + $this->app->getContainer()->get('store')->commitIfNecessary('api_requests_history'); $logger->setUserId($user->userId); $this->app->getContainer()->set('user', $user); diff --git a/lib/Report/ApiRequests.php b/lib/Report/ApiRequests.php index 2ca9bd7a07..085bb8df2b 100644 --- a/lib/Report/ApiRequests.php +++ b/lib/Report/ApiRequests.php @@ -28,6 +28,7 @@ use Xibo\Entity\ReportForm; use Xibo\Entity\ReportResult; use Xibo\Entity\ReportSchedule; +use Xibo\Factory\ApplicationRequestsFactory; use Xibo\Factory\AuditLogFactory; use Xibo\Factory\LogFactory; use Xibo\Helper\DateFormatHelper; @@ -45,11 +46,15 @@ class ApiRequests implements ReportInterface /** @var AuditLogFactory */ private $auditLogFactory; + /** @var ApplicationRequestsFactory */ + private $apiRequestsFactory; + /** @inheritdoc */ public function setFactories(ContainerInterface $container) { $this->logFactory = $container->get('logFactory'); $this->auditLogFactory = $container->get('auditLogFactory'); + $this->apiRequestsFactory = $container->get('apiRequestsFactory'); return $this; } @@ -319,7 +324,7 @@ public function getResults(SanitizerInterface $sanitizedParams) $rows, count($rows), ); - } else { + } else if ($type === 'debug') { $params = [ 'fromDt' => $fromDt->format(DateFormatHelper::getSystemFormat()), 'toDt' => $toDt->format(DateFormatHelper::getSystemFormat()), @@ -395,6 +400,64 @@ public function getResults(SanitizerInterface $sanitizedParams) $rows[] = $logRecord; } + return new ReportResult( + $metadata, + $rows, + count($rows), + ); + } else { + $params = [ + 'fromDt' => $fromDt->format(DateFormatHelper::getSystemFormat()), + 'toDt' => $toDt->format(DateFormatHelper::getSystemFormat()), + ]; + + $sql = 'SELECT + `application_requests_history`.applicationId, + `application_requests_history`.requestId, + `application_requests_history`.userId, + `application_requests_history`.url, + `application_requests_history`.method, + `application_requests_history`.startTime, + `oauth_clients`.name AS applicationName, + `user`.`userName` + FROM `application_requests_history` + INNER JOIN `user` + ON `user`.`userId` = `application_requests_history`.`userId` + INNER JOIN `oauth_clients` + ON `oauth_clients`.id = `application_requests_history`.applicationId + WHERE `application_requests_history`.startTime BETWEEN :fromDt AND :toDt + '; + + if ($sanitizedParams->getInt('userId') !== null) { + $sql .= ' AND `application_requests_history`.`userId` = :userId'; + $params['userId'] = $sanitizedParams->getInt('userId'); + } + + // Sorting? + $sortOrder = $this->gridRenderSort($sanitizedParams); + + if (is_array($sortOrder)) { + $sql .= ' ORDER BY ' . implode(',', $sortOrder); + } + + $rows = []; + + foreach ($this->store->select($sql, $params) as $row) { + $apiRequestRecord = $this->apiRequestsFactory->createEmpty()->hydrate($row); + + $apiRequestRecord->setUnmatchedProperty( + 'userName', + $row['userName'] + ); + + $apiRequestRecord->setUnmatchedProperty( + 'applicationName', + $row['applicationName'] + ); + + $rows[] = $apiRequestRecord; + } + return new ReportResult( $metadata, $rows, diff --git a/reports/apirequests-email-template.twig b/reports/apirequests-email-template.twig index 3422097c70..d8ef7743db 100644 --- a/reports/apirequests-email-template.twig +++ b/reports/apirequests-email-template.twig @@ -58,7 +58,7 @@ {{ item.objectAfter }} {% endfor %} - {% else %} + {% elseif metadata.logType == 'debug' %} {% trans "Date" %} {% trans "UserName" %} @@ -83,6 +83,27 @@ {{ item.message }} {% endfor %} + {% else %} + + {% trans "Date" %} + {% trans "UserName" %} + {% trans "User ID" %} + {% trans "Application" %} + {% trans "Request ID" %} + {% trans "Method" %} + {% trans "Url" %} + + {% for item in tableData %} + + {{ item.startTime }} + {{ item.userName }} + {{ item.userId }} + {{ item.applicationName }} + {{ item.requestId }} + {{ item.method }} + {{ item.url }} + + {% endfor %} {% endif %}
diff --git a/reports/apirequests-report-form.twig b/reports/apirequests-report-form.twig index b876eb0c4a..8deecf7fb2 100644 --- a/reports/apirequests-report-form.twig +++ b/reports/apirequests-report-form.twig @@ -68,6 +68,7 @@ {% set title = "Report Type"|trans %} {% set options = [ + { id: 'requests', value: "Requests"|trans }, { id: 'audit', value: "Audit"|trans }, { id: 'debug', value: "Debug"|trans }, ] %} @@ -140,6 +141,31 @@
+ +
+ +
+ + + + + + + + + + + + + + + +
{% trans "Date" %}{% trans "UserName" %}{% trans "User ID" %}{% trans "Application" %}{% trans "Request ID" %}{% trans "Method" %}{% trans "Url" %}
+
+
@@ -176,12 +202,15 @@ $('[data-toggle="popover"]').popover(); let $report = $('#apiRequestsHistoryFilter'); - let $auditDataTable = $('#apiRequestsHistoryAuditGrid') + let $auditDataTable = $('#apiRequestsHistoryAuditGrid'); let auditTable = createAuditTable($auditDataTable); - let $logDataTable = $('#apiRequestsHistoryLogGrid') - let logTable = createLogTable($logDataTable) + let $logDataTable = $('#apiRequestsHistoryLogGrid'); + let logTable = createLogTable($logDataTable); + let $requestsDataTable = $('#apiRequestsHistoryGrid'); + let requestsTable = createRequestsTable($requestsDataTable); let result; // XHR get data result + let reportType = $report.find('#type').val(); let imageLoader = $("#imageLoader"); let $warning = $("#applyWarning"); @@ -198,14 +227,22 @@ result = data; $applyBtn.removeClass('disabled'); imageLoader.removeClass('fa fa-spinner fa-spin loading-icon'); - if ($report.find('#type').val() === 'audit') { + + if (reportType === 'audit') { $('.api-requests-history-audit').removeClass('d-none'); $('.api-requests-history-log').addClass('d-none'); + $('.api-requests-history').addClass('d-none'); setTabularData(auditTable, result.table); - } else { - $('.api-requests-history-audit').addClass('d-none'); + } else if (reportType === 'debug') { $('.api-requests-history-log').removeClass('d-none'); + $('.api-requests-history-audit').addClass('d-none'); + $('.api-requests-history').addClass('d-none'); setTabularData(logTable, result.table); + } else { + $('.api-requests-history').removeClass('d-none'); + $('.api-requests-history-audit').addClass('d-none'); + $('.api-requests-history-log').addClass('d-none'); + setTabularData(requestsTable, result.table); } }, error: function error(xhr, textStatus, _error) { @@ -233,16 +270,20 @@ // Apply $applyBtn.click(function () { + reportType = $report.find('#type').val(); $(this).addClass('disabled'); imageLoader.addClass('fa fa-spinner fa-spin loading-icon'); getData($auditDataTable.data().url); }); $("#refreshGrid").click(function () { - if ($report.find('#type').val() === 'audit') { + reportType = $report.find('#type').val(); + if (reportType === 'audit') { auditTable.ajax.reload(); - } else { + } else if (reportType === 'debug') { logTable.ajax.reload(); + } else { + requestsTable.ajax.reload(); } }); @@ -322,6 +363,31 @@ ] }) } + + function createRequestsTable($dataTable) { + return $dataTable.DataTable({ + language: dataTablesLanguage, + dom: dataTablesTemplate, + searching: false, + paging: true, + bInfo: false, + stateSave: true, + bDestroy: true, + processing: true, + responsive: true, + order: [[0, 'desc']], + data: {}, + columns: [ + {data: 'startTime'}, + {data: 'userName'}, + {data: 'userId'}, + {data: 'applicationName'}, + {data: 'requestId'}, + {data: 'method'}, + {data: 'url'}, + ] + }) + } }); {% endblock %} \ No newline at end of file diff --git a/reports/apirequests-report-preview.twig b/reports/apirequests-report-preview.twig index 9114b90cb8..4272176f7e 100644 --- a/reports/apirequests-report-preview.twig +++ b/reports/apirequests-report-preview.twig @@ -66,7 +66,7 @@ - {% else %} + {% elseif metadata.logType == 'debug' %}
@@ -84,6 +84,25 @@ + +
+
+ {% else %} +
+ + + + + + + + + + + + + +
{% trans "Date" %}{% trans "UserName" %}{% trans "User ID" %}{% trans "Application" %}{% trans "Request ID" %}{% trans "Method" %}{% trans "Url" %}
@@ -139,7 +158,7 @@ auditTable.on('processing.dt', function(e, settings, processing) { dataTableProcessing(e, settings, processing); }); - } else { + } else if (type === 'debug') { const debugTable = $("#apiRequestsHistoryAuditReportPreview").DataTable({ language: dataTablesLanguage, dom: dataTablesTemplate, @@ -166,6 +185,31 @@ debugTable.on('processing.dt', function(e, settings, processing) { dataTableProcessing(e, settings, processing); }); + } else { + const requestsTable = $("#apiRequestsHistoryReportPreview").DataTable({ + language: dataTablesLanguage, + dom: dataTablesTemplate, + paging: false, + ordering: false, + info: false, + order: [[0, 'desc']], + searching: false, + data: outputData, + columns: [ + {data: 'startTime'}, + {data: 'userName'}, + {data: 'userId'}, + {data: 'applicationName'}, + {data: 'requestId'}, + {data: 'method'}, + {data: 'url'}, + ] + }); + + requestsTable.on('draw', dataTableDraw); + requestsTable.on('processing.dt', function(e, settings, processing) { + dataTableProcessing(e, settings, processing); + }); } }); diff --git a/reports/apirequests-schedule-form-add.twig b/reports/apirequests-schedule-form-add.twig index b4837e2d4b..add3fa0e9d 100644 --- a/reports/apirequests-schedule-form-add.twig +++ b/reports/apirequests-schedule-form-add.twig @@ -72,6 +72,7 @@ {% set title = "Report Type"|trans %} {% set options = [ + { id: 'requests', value: "Requests"|trans }, { id: 'audit', value: "Audit"|trans }, { id: 'debug', value: "Debug"|trans }, ] %} From cf6a331fe79b95852260d02165f0336c69f34a47 Mon Sep 17 00:00:00 2001 From: Mae Grace Baybay <87640602+mgbaybay@users.noreply.github.com> Date: Wed, 7 Aug 2024 15:10:36 +0800 Subject: [PATCH 174/203] Bandwidth Report: Fixed data error for displays with same name (#2684) --- lib/Report/Bandwidth.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Report/Bandwidth.php b/lib/Report/Bandwidth.php index 3fb2bc333f..2d18f37f46 100644 --- a/lib/Report/Bandwidth.php +++ b/lib/Report/Bandwidth.php @@ -255,7 +255,7 @@ public function getResults(SanitizerInterface $sanitizedParams) $params['displayid'] = $displayId; } - $SQL .= 'GROUP BY display.display '; + $SQL .= 'GROUP BY display.displayId, display.display '; if ($displayId != 0) { $SQL .= ' , bandwidthtype.name '; From 80b27610e0b88884fac8320f704093a4b75c2caf Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Wed, 7 Aug 2024 12:01:59 +0100 Subject: [PATCH 175/203] Release prep: 4.1.0-rc2 (#2690) xibosignageltd/xibo-private#830 --- lib/Helper/Environment.php | 2 +- locale/default.pot | 982 +++++++++++++++++++------------------ 2 files changed, 497 insertions(+), 487 deletions(-) diff --git a/lib/Helper/Environment.php b/lib/Helper/Environment.php index e3e52fffd3..7efd7729a8 100644 --- a/lib/Helper/Environment.php +++ b/lib/Helper/Environment.php @@ -30,7 +30,7 @@ */ class Environment { - public static $WEBSITE_VERSION_NAME = '4.1.0-rc1'; + public static $WEBSITE_VERSION_NAME = '4.1.0-rc2'; public static $XMDS_VERSION = '7'; public static $XLF_VERSION = 4; public static $VERSION_REQUIRED = '8.1.0'; diff --git a/locale/default.pot b/locale/default.pot index 089b501cb6..4398cdabc3 100755 --- a/locale/default.pot +++ b/locale/default.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-07-24 10:13+0100\n" +"POT-Creation-Date: 2024-08-07 09:50+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -20,7 +20,7 @@ msgstr "" #: locale/moduletranslate.php:3 locale/dbtranslate.php:73 #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:1996 #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:2230 -#: cache/1b/1b4d10b8bb67ce8b30ada153b15daf6e.php:510 +#: cache/1b/1b4d10b8bb67ce8b30ada153b15daf6e.php:500 msgid "Audio" msgstr "" @@ -1415,7 +1415,7 @@ msgid "Upload SWF files to assign to Layouts" msgstr "" #: locale/moduletranslate.php:385 locale/dbtranslate.php:99 -#: lib/Connector/OpenWeatherMapConnector.php:652 +#: lib/Connector/OpenWeatherMapConnector.php:667 msgid "Weather" msgstr "" @@ -1792,9 +1792,9 @@ msgid "Leave empty to use the one from settings." msgstr "" #: locale/moduletranslate.php:501 locale/moduletranslate.php:2646 -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:375 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:427 #: cache/0a/0ae358955c92f467744fc6cf0f8f5624.php:130 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:354 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:371 #: cache/58/582bf6a35fd9661de4021ae6aad02414.php:198 #: cache/cd/cdf12d9a1fc3b5db7965cfa8f9bb8d13.php:132 #: cache/53/53221a3cb3187e41cde32cfa796d45a7.php:123 @@ -1807,8 +1807,8 @@ msgid "Provide Mastodon username to get public statuses from the account." msgstr "" #: locale/moduletranslate.php:503 -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:581 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:560 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:633 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:577 #: lib/Report/DistributionReport.php:490 #: lib/Report/SummaryDistributionCommonTrait.php:89 #: lib/Report/SummaryReport.php:482 @@ -2326,8 +2326,8 @@ msgid "" msgstr "" #: locale/moduletranslate.php:602 -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:420 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:399 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:472 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:416 msgid "User Agent" msgstr "" @@ -4373,12 +4373,12 @@ msgid "Icon" msgstr "" #: locale/moduletranslate.php:1798 -#: lib/Connector/OpenWeatherMapConnector.php:675 +#: lib/Connector/OpenWeatherMapConnector.php:690 msgid "Wind Direction" msgstr "" #: locale/moduletranslate.php:1799 -#: lib/Connector/OpenWeatherMapConnector.php:673 +#: lib/Connector/OpenWeatherMapConnector.php:688 msgid "Wind Speed" msgstr "" @@ -4614,11 +4614,11 @@ msgstr "" #: locale/moduletranslate.php:2358 locale/moduletranslate.php:2382 #: locale/moduletranslate.php:2414 #: cache/c8/c81aa074ed0c595eefef0776b8c89639.php:624 -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:337 -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:571 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:389 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:623 #: cache/80/8016dad6634116cf39ffaf1bbf646931.php:681 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:316 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:550 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:333 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:567 #: cache/bf/bfa04d76ea96a772caeedcfdf9d70e40.php:231 #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3153 #: cache/38/38e5186bcfa9917c4ec02d8c626e2552.php:361 @@ -5644,7 +5644,7 @@ msgstr "" #: locale/dbtranslate.php:37 cache/c8/c81aa074ed0c595eefef0776b8c89639.php:856 #: cache/80/8016dad6634116cf39ffaf1bbf646931.php:912 -#: cache/66/664c6c931a53dbf67110ec2cb80223cf.php:238 +#: cache/66/664c6c931a53dbf67110ec2cb80223cf.php:243 #: cache/0e/0ed8020e97c517a55ad881cfc5dd0b4c.php:306 #: cache/cb/cb1c15ce617fd7e7648d38bb0b2b48f5.php:616 #: cache/cb/cb1c15ce617fd7e7648d38bb0b2b48f5.php:758 @@ -6002,11 +6002,11 @@ msgstr "" msgid "Right now" msgstr "" -#: locale/dbtranslate.php:167 lib/Connector/OpenWeatherMapConnector.php:698 +#: locale/dbtranslate.php:167 lib/Connector/OpenWeatherMapConnector.php:713 msgid "Pressure" msgstr "" -#: locale/dbtranslate.php:168 lib/Connector/OpenWeatherMapConnector.php:700 +#: locale/dbtranslate.php:168 lib/Connector/OpenWeatherMapConnector.php:715 msgid "Visibility" msgstr "" @@ -6332,7 +6332,7 @@ msgstr "" #: cache/5e/5ec96adf0346509994d4c975eac80689.php:69 #: cache/fb/fb4bc86a9035b6ec146f3221bdb0c2c1.php:69 #: cache/7f/7fc756b1f76699c2b9e7d2bfb64e9999.php:69 -#: cache/66/664c6c931a53dbf67110ec2cb80223cf.php:134 +#: cache/66/664c6c931a53dbf67110ec2cb80223cf.php:139 #: cache/81/811403ea85a4f89fb6575e4ffc9988d8.php:72 #: cache/d7/d7326d049226360861decd025d796004.php:72 #: cache/d7/d7ce5b1f05d3a691d8332e778a077544.php:75 @@ -6772,9 +6772,9 @@ msgstr "" #: cache/10/10b7f3b85c460e6ccc00e28eed2bf591.php:656 #: cache/70/70af2bbd82243291558c9b83caec5b48.php:161 #: lib/Controller/Template.php:233 lib/Controller/DisplayGroup.php:348 -#: lib/Controller/DataSet.php:280 lib/Controller/Campaign.php:348 +#: lib/Controller/DataSet.php:282 lib/Controller/Campaign.php:348 #: lib/Controller/MenuBoard.php:170 lib/Controller/Playlist.php:411 -#: lib/Controller/Library.php:667 lib/Controller/Layout.php:1810 +#: lib/Controller/Library.php:667 lib/Controller/Layout.php:1811 #: lib/Controller/Display.php:943 msgid "Select Folder" msgstr "" @@ -7098,7 +7098,6 @@ msgstr "" #: cache/3d/3d685e4b4f0f4e045b5cc2697b26574d.php:88 #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:2358 #: cache/1b/1b4d10b8bb67ce8b30ada153b15daf6e.php:481 -#: cache/1b/1b4d10b8bb67ce8b30ada153b15daf6e.php:500 msgid "Region" msgstr "" @@ -7127,13 +7126,13 @@ msgstr "" #: cache/fe/fe92f223f863fb1896ced5bd33b8c256.php:560 #: cache/11/1107af5e0742401bd7bdcb180ef3991e.php:177 #: lib/Controller/Task.php:136 lib/Controller/Template.php:225 -#: lib/Controller/DisplayGroup.php:330 lib/Controller/DataSet.php:272 +#: lib/Controller/DisplayGroup.php:330 lib/Controller/DataSet.php:274 #: lib/Controller/ScheduleReport.php:216 lib/Controller/Notification.php:270 #: lib/Controller/Campaign.php:327 lib/Controller/Campaign.php:335 #: lib/Controller/MenuBoard.php:162 lib/Controller/Playlist.php:396 #: lib/Controller/Transition.php:94 lib/Controller/Library.php:652 #: lib/Controller/SyncGroup.php:170 lib/Controller/DataSetColumn.php:173 -#: lib/Controller/Tag.php:231 lib/Controller/Layout.php:1802 +#: lib/Controller/Tag.php:231 lib/Controller/Layout.php:1803 #: lib/Controller/MenuBoardCategory.php:191 #: lib/Controller/DisplayProfile.php:202 lib/Controller/User.php:337 #: lib/Controller/Applications.php:178 lib/Controller/Resolution.php:161 @@ -7166,8 +7165,8 @@ msgstr "" #: cache/11/1107af5e0742401bd7bdcb180ef3991e.php:181 #: lib/Controller/Task.php:143 lib/Controller/Template.php:268 #: lib/Controller/Template.php:281 lib/Controller/DisplayGroup.php:376 -#: lib/Controller/DisplayGroup.php:389 lib/Controller/DataSet.php:326 -#: lib/Controller/DataSet.php:332 lib/Controller/ScheduleReport.php:236 +#: lib/Controller/DisplayGroup.php:389 lib/Controller/DataSet.php:328 +#: lib/Controller/DataSet.php:334 lib/Controller/ScheduleReport.php:236 #: lib/Controller/ScheduleReport.php:242 lib/Controller/Notification.php:287 #: lib/Controller/Campaign.php:393 lib/Controller/Campaign.php:406 #: lib/Controller/SavedReport.php:177 lib/Controller/SavedReport.php:183 @@ -7175,8 +7174,8 @@ msgstr "" #: lib/Controller/Playlist.php:472 lib/Controller/Library.php:692 #: lib/Controller/Library.php:698 lib/Controller/SyncGroup.php:184 #: lib/Controller/DataSetColumn.php:181 lib/Controller/Tag.php:238 -#: lib/Controller/Tag.php:244 lib/Controller/Layout.php:1860 -#: lib/Controller/Layout.php:1866 lib/Controller/MenuBoardCategory.php:201 +#: lib/Controller/Tag.php:244 lib/Controller/Layout.php:1861 +#: lib/Controller/Layout.php:1867 lib/Controller/MenuBoardCategory.php:201 #: lib/Controller/Font.php:172 lib/Controller/Font.php:181 #: lib/Controller/DisplayProfile.php:223 lib/Controller/User.php:351 #: lib/Controller/Applications.php:185 lib/Controller/Resolution.php:172 @@ -7425,7 +7424,7 @@ msgstr "" #: cache/c5/c52b8109558dd3acc604e183177113c8.php:262 #: cache/cb/cb1c15ce617fd7e7648d38bb0b2b48f5.php:474 #: cache/10/10b7f3b85c460e6ccc00e28eed2bf591.php:933 -#: lib/Controller/Layout.php:1272 +#: lib/Controller/Layout.php:1273 msgid "Off" msgstr "" @@ -7446,7 +7445,7 @@ msgstr "" #: cache/23/2360e54d15ebe919cd8f4305d3819359.php:296 #: cache/c5/c52b8109558dd3acc604e183177113c8.php:267 #: cache/10/10b7f3b85c460e6ccc00e28eed2bf591.php:937 -#: lib/Controller/Layout.php:1272 +#: lib/Controller/Layout.php:1273 msgid "On" msgstr "" @@ -7892,14 +7891,14 @@ msgid "Per Minute" msgstr "" #: cache/c8/c81aa074ed0c595eefef0776b8c89639.php:634 -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:643 -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:716 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:695 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:768 #: cache/3c/3c7bd12ad2faa21d9e80b1c4ee69d3b4.php:139 #: cache/68/683636238aff529d5a7b7478118dede4.php:1076 #: cache/80/8016dad6634116cf39ffaf1bbf646931.php:691 #: cache/09/09ce3b6a0541b31d12645b44531faf96.php:139 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:619 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:692 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:636 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:709 #: cache/49/49b3c02c876e8a464a75e1682c064115.php:150 #: cache/74/74cac03bd6d6bdd54edcfd79c1c2a2fb.php:161 #: cache/38/38e5186bcfa9917c4ec02d8c626e2552.php:371 @@ -7908,14 +7907,14 @@ msgid "Hourly" msgstr "" #: cache/c8/c81aa074ed0c595eefef0776b8c89639.php:639 -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:648 -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:721 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:700 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:773 #: cache/3c/3c7bd12ad2faa21d9e80b1c4ee69d3b4.php:139 #: cache/68/683636238aff529d5a7b7478118dede4.php:1081 #: cache/80/8016dad6634116cf39ffaf1bbf646931.php:696 #: cache/09/09ce3b6a0541b31d12645b44531faf96.php:139 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:624 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:697 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:641 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:714 #: cache/49/49b3c02c876e8a464a75e1682c064115.php:150 #: cache/74/74cac03bd6d6bdd54edcfd79c1c2a2fb.php:161 #: cache/38/38e5186bcfa9917c4ec02d8c626e2552.php:376 @@ -7924,33 +7923,33 @@ msgid "Daily" msgstr "" #: cache/c8/c81aa074ed0c595eefef0776b8c89639.php:644 -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:653 -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:726 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:705 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:778 #: cache/80/8016dad6634116cf39ffaf1bbf646931.php:701 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:629 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:702 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:646 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:719 #: cache/38/38e5186bcfa9917c4ec02d8c626e2552.php:381 #: cache/0e/0e02100c0f3615b819bb09d3e2a17b8a.php:428 msgid "Weekly" msgstr "" #: cache/c8/c81aa074ed0c595eefef0776b8c89639.php:649 -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:663 -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:736 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:715 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:788 #: cache/80/8016dad6634116cf39ffaf1bbf646931.php:706 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:639 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:712 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:656 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:729 #: cache/38/38e5186bcfa9917c4ec02d8c626e2552.php:386 #: cache/0e/0e02100c0f3615b819bb09d3e2a17b8a.php:433 msgid "Monthly" msgstr "" #: cache/c8/c81aa074ed0c595eefef0776b8c89639.php:654 -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:673 -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:746 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:725 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:798 #: cache/80/8016dad6634116cf39ffaf1bbf646931.php:711 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:649 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:722 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:666 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:739 #: cache/38/38e5186bcfa9917c4ec02d8c626e2552.php:391 #: cache/0e/0e02100c0f3615b819bb09d3e2a17b8a.php:438 msgid "Yearly" @@ -8148,7 +8147,7 @@ msgstr "" #: cache/c8/c81aa074ed0c595eefef0776b8c89639.php:848 #: cache/80/8016dad6634116cf39ffaf1bbf646931.php:904 -#: cache/66/664c6c931a53dbf67110ec2cb80223cf.php:234 +#: cache/66/664c6c931a53dbf67110ec2cb80223cf.php:239 msgid "Metric" msgstr "" @@ -8408,7 +8407,7 @@ msgstr "" #: cache/64/6468accf3a0fea12fced661d430c769f.php:97 #: cache/df/dfa8c9877043c738d127ce9a84cd99fa.php:146 #: cache/97/97f2c018825ccdd326aad4a0b160725e.php:175 -#: cache/1b/1b4d10b8bb67ce8b30ada153b15daf6e.php:550 +#: cache/1b/1b4d10b8bb67ce8b30ada153b15daf6e.php:530 #: cache/23/238f0b98d7f96aca9a850e8b230fe1fb.php:277 #: cache/8d/8de7dd9661211f3ba39933b564d76990.php:100 #: cache/cb/cbed339b261aade59b9d7cdbdfb46dcc.php:461 @@ -8465,9 +8464,9 @@ msgstr "" #: cache/73/736be9c5f96ff30502beba0ab9149fd4.php:72 #: cache/31/31805080e1e51d4a56cb67fa60bdcb2f.php:72 #: lib/Controller/Template.php:257 lib/Controller/DisplayGroup.php:336 -#: lib/Controller/DataSet.php:262 lib/Controller/Campaign.php:376 +#: lib/Controller/DataSet.php:264 lib/Controller/Campaign.php:376 #: lib/Controller/Playlist.php:403 lib/Controller/Library.php:659 -#: lib/Controller/Layout.php:1827 lib/Controller/DisplayProfile.php:212 +#: lib/Controller/Layout.php:1828 lib/Controller/DisplayProfile.php:212 #: lib/Controller/Developer.php:117 lib/Controller/UserGroup.php:174 msgid "Copy" msgstr "" @@ -8751,9 +8750,9 @@ msgid "Remote" msgstr "" #: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:100 -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:327 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:379 #: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:100 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:306 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:323 msgid "Authentication" msgstr "" @@ -8804,167 +8803,189 @@ msgstr "" msgid "Is this DataSet connected to a real time data source?" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:237 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:236 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:228 +msgid "Data Connector Source" +msgstr "" + +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:241 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:233 +msgid "Select data connector source." +msgstr "" + +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:260 +msgid "" +"No remote columns have been configured for this dataset. Please configure " +"your columns accordingly." +msgstr "" + +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:275 +msgid "" +"No value columns have been configured for this dataset. Please configure " +"your columns accordingly." +msgstr "" + +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:289 msgid "" "This DataSet has been accessed or updated recently, which means the CMS will " "keep it active." msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:247 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:229 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:299 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:246 msgid "Method" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:252 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:234 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:304 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:251 msgid "What type of request needs to be made to get the remote data?" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:257 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:309 #: cache/04/04b11bf7b7a8190d66899471a8930ffd.php:228 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:239 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:256 msgid "GET" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:262 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:314 #: cache/04/04b11bf7b7a8190d66899471a8930ffd.php:233 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:244 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:261 msgid "POST" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:277 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:259 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:329 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:276 msgid "URI" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:282 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:264 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:334 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:281 msgid "URL to the Remote DataSet for GET and POST." msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:294 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:276 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:346 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:293 msgid "Replacements" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:298 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:280 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:350 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:297 msgid "Request date: {{DATE}}" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:302 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:284 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:354 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:301 msgid "Request time: {{TIME}}" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:306 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:288 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:358 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:305 msgid "" "Dependant fields: {{COL.NAME}} where NAME is a FieldName from the dependant " "DataSet" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:312 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:294 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:364 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:311 msgid "" "Data to add to this request. This should be URL encoded, e.g. paramA=1&" "paramB=2." msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:332 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:311 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:384 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:328 msgid "" "Select the authentication requirements for the remote data source. These " "will be added to the request." msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:342 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:321 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:394 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:338 msgid "Basic" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:347 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:326 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:399 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:343 msgid "Digest" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:352 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:331 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:404 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:348 msgid "NTLM" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:357 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:336 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:409 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:353 msgid "Bearer" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:380 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:359 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:432 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:376 msgid "Enter the authentication Username" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:390 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:442 #: cache/9f/9fdba8e23ed4ab1fa30c86aed8306cba.php:219 #: cache/0a/0ae358955c92f467744fc6cf0f8f5624.php:145 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:369 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:386 #: cache/64/6468accf3a0fea12fced661d430c769f.php:236 #: cache/58/582bf6a35fd9661de4021ae6aad02414.php:213 #: cache/37/377ff96eb23f94563370a45d1c41993f.php:114 msgid "Password" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:395 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:374 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:447 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:391 msgid "Corresponding Password" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:405 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:384 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:457 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:401 msgid "Custom Headers" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:410 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:389 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:462 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:406 msgid "" "Comma separated string of custom HTTP headers in headerName:headerValue " "format" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:425 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:404 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:477 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:421 msgid "" "Optionally set specific User Agent for this request, provide only the value, " "relevant header will be added automatically" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:437 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:489 #: cache/9f/9fdba8e23ed4ab1fa30c86aed8306cba.php:322 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:416 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:433 msgid "Source" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:442 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:421 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:494 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:438 msgid "Select source type of the provided remote Dataset URL" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:447 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:426 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:499 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:443 msgid "JSON" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:452 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:431 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:504 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:448 msgid "CSV" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:467 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:446 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:519 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:463 msgid "Data root" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:472 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:451 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:524 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:468 msgid "" "Please enter the element in your remote data which we should use as the " "starting point when we match the remote Columns. This should be an array or " @@ -8972,149 +8993,149 @@ msgid "" "returned." msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:482 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:461 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:534 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:478 msgid "CSV separator" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:487 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:466 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:539 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:483 msgid "What separator should be used for CSV source?" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:492 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:471 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:544 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:488 msgid "Comma" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:498 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:477 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:550 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:494 msgid "Semicolon" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:504 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:483 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:556 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:500 msgid "Space" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:510 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:489 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:562 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:506 msgid "Tab" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:516 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:495 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:568 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:512 msgid "Pipe" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:535 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:514 -#: cache/b2/b21687508ff5b0bb637fdbd3f8c0d3c6.php:665 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:587 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:531 +#: cache/b2/b21687508ff5b0bb637fdbd3f8c0d3c6.php:688 msgid "Ignore first row?" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:540 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:519 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:592 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:536 msgid "For CSV source, should the first row be ignored?" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:552 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:531 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:604 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:548 msgid "Test data URL" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:561 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:540 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:613 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:557 msgid "Aggregation" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:566 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:545 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:618 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:562 msgid "Aggregate received data by the given method" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:576 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:555 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:628 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:572 msgid "Summarize" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:597 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:576 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:649 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:593 msgid "By Field" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:607 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:583 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:659 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:600 msgid "" "Using JSON syntax enter the path below the Data root by which the above " "aggregation should be applied." msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:611 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:587 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:663 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:604 msgid "" "Summarize: Values in this field will be summarized and stored in one column." msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:615 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:591 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:667 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:608 msgid "" "Count: All individual values in this field will be counted and stored in one " "Column for each value" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:626 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:678 #: cache/04/04b11bf7b7a8190d66899471a8930ffd.php:79 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:602 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:619 msgid "Refresh" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:631 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:607 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:683 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:624 msgid "How often should this remote data be fetched and imported?" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:638 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:614 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:690 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:631 msgid "Constantly" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:658 -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:731 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:634 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:707 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:710 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:783 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:651 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:724 msgid "Every two Weeks" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:668 -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:741 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:644 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:717 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:720 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:793 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:661 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:734 msgid "Quaterly" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:694 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:670 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:746 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:687 msgid "Truncate DataSet" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:699 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:675 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:751 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:692 msgid "" "Select when you would like the Data to be truncated out of this DataSet. The " "criteria is assessed when synchronisation occurs and is truncated before " "adding new data." msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:706 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:682 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:758 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:699 #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:2955 #: lib/Controller/Library.php:581 msgid "Never" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:711 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:687 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:763 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:704 #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:2959 #: cache/a7/a7a26926ec2b52230c8037d640a807ea.php:1464 #: cache/a7/a7a26926ec2b52230c8037d640a807ea.php:1932 @@ -9122,71 +9143,71 @@ msgstr "" msgid "Always" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:751 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:727 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:803 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:744 msgid "Every second Year" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:774 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:750 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:826 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:767 msgid "Truncate with no new data?" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:779 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:755 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:831 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:772 msgid "" "Should the DataSet data be truncated even if no new data is pulled from the " "source?" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:789 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:765 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:841 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:782 msgid "Depends on DataSet" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:797 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:773 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:849 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:790 msgid "" "The DataSet you select here will be processed in advance and have its values " "available for subsitution in the data to add to this request on the Remote " "tab." msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:807 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:783 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:859 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:800 msgid "Row Limit" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:812 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:788 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:864 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:805 msgid "" "Optionally provide a row limit for this DataSet. When left empty the DataSet " "row limit from CMS Settings will be used." msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:822 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:798 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:874 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:815 msgid "Limit Policy" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:827 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:803 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:879 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:820 msgid "What should happen when this Dataset reaches the row limit?" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:832 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:808 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:884 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:825 msgid "Stop Syncing" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:837 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:813 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:889 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:830 msgid "First In First Out" msgstr "" -#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:842 +#: cache/86/86d9a0599e765017f2e6f2e4a7025625.php:894 #: cache/04/04b11bf7b7a8190d66899471a8930ffd.php:73 -#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:818 +#: cache/41/413dbe6a803c413d6ff11377888e1e0d.php:835 #: cache/dc/dc491f32668885ef8be3c5ae4c7dc6e5.php:56 msgid "Truncate" msgstr "" @@ -9271,7 +9292,7 @@ msgstr "" #: cache/a7/a7a26926ec2b52230c8037d640a807ea.php:117 #: cache/a7/a7a26926ec2b52230c8037d640a807ea.php:1617 #: lib/Controller/Campaign.php:297 lib/Controller/Playlist.php:541 -#: lib/Controller/Library.php:784 lib/Controller/Layout.php:1750 +#: lib/Controller/Library.php:784 lib/Controller/Layout.php:1751 #: lib/Controller/Display.php:1004 msgid "Schedule" msgstr "" @@ -9372,7 +9393,7 @@ msgstr "" #: cache/0e/0ed8020e97c517a55ad881cfc5dd0b4c.php:71 #: cache/fa/faae83213ac48b5ddd10aa2f75cd5121.php:56 #: cache/fa/faae83213ac48b5ddd10aa2f75cd5121.php:69 -#: lib/Controller/Template.php:340 lib/Controller/Layout.php:1905 +#: lib/Controller/Template.php:340 lib/Controller/Layout.php:1906 msgid "Export" msgstr "" @@ -11325,7 +11346,7 @@ msgid "Delete Rows" msgstr "" #: cache/14/14220f2427744b387c8a9518e0bf00d2.php:91 -#: lib/Controller/DataSet.php:224 +#: lib/Controller/DataSet.php:226 msgid "View Columns" msgstr "" @@ -11708,7 +11729,7 @@ msgid "" msgstr "" #: cache/eb/eb722a59dac7c3fddd70255e2c4c5f1d.php:56 -#: lib/Controller/DataSet.php:307 lib/Controller/Module.php:129 +#: lib/Controller/DataSet.php:309 lib/Controller/Module.php:129 msgid "Clear Cache" msgstr "" @@ -11740,7 +11761,7 @@ msgstr "" #: cache/80/8016dad6634116cf39ffaf1bbf646931.php:299 #: cache/04/04b11bf7b7a8190d66899471a8930ffd.php:143 -#: lib/Report/TimeDisconnectedSummary.php:310 +#: lib/Report/TimeDisconnectedSummary.php:311 msgid "Hours" msgstr "" @@ -11750,7 +11771,7 @@ msgstr "" #: cache/80/8016dad6634116cf39ffaf1bbf646931.php:314 #: cache/04/04b11bf7b7a8190d66899471a8930ffd.php:138 -#: lib/Report/TimeDisconnectedSummary.php:313 +#: lib/Report/TimeDisconnectedSummary.php:314 msgid "Minutes" msgstr "" @@ -11816,7 +11837,7 @@ msgstr "" #: cache/9f/9f9a142004ca4a1c914a4395fe586017.php:125 #: cache/a4/a4b5f52b1992bb349e8a30b19ff24645.php:80 #: lib/Controller/Playlist.php:1706 lib/Controller/Library.php:2142 -#: lib/Controller/Layout.php:1680 +#: lib/Controller/Layout.php:1681 msgid "Design" msgstr "" @@ -12756,7 +12777,7 @@ msgstr "" #: cache/04/04b11bf7b7a8190d66899471a8930ffd.php:55 #: cache/04/04b11bf7b7a8190d66899471a8930ffd.php:93 -#: cache/66/664c6c931a53dbf67110ec2cb80223cf.php:154 +#: cache/66/664c6c931a53dbf67110ec2cb80223cf.php:159 msgid "Logs" msgstr "" @@ -13056,7 +13077,7 @@ msgstr "" #: cache/88/88e10283780c820d9196a2e88b3b8e39.php:77 #: cache/88/88e10283780c820d9196a2e88b3b8e39.php:81 -#: lib/Controller/DataSet.php:213 +#: lib/Controller/DataSet.php:215 msgid "View Data" msgstr "" @@ -13598,41 +13619,29 @@ msgstr "" #: cache/6e/6e8678de8ffffc5ed3b935eb9c3f6eb0.php:316 msgid "" -"Self-paced online training courses designed to get you up and running with " +"Self-paced online training videos designed to get you up and running with " "Xibo in no time." msgstr "" #: cache/6e/6e8678de8ffffc5ed3b935eb9c3f6eb0.php:320 -msgid "Enrol on new user training or admin essentials for free." -msgstr "" - -#: cache/6e/6e8678de8ffffc5ed3b935eb9c3f6eb0.php:324 msgid "New User Training" msgstr "" -#: cache/6e/6e8678de8ffffc5ed3b935eb9c3f6eb0.php:328 -msgid "Admin Essentials" -msgstr "" - -#: cache/6e/6e8678de8ffffc5ed3b935eb9c3f6eb0.php:332 -msgid "Training School" -msgstr "" - -#: cache/6e/6e8678de8ffffc5ed3b935eb9c3f6eb0.php:345 +#: cache/6e/6e8678de8ffffc5ed3b935eb9c3f6eb0.php:333 #: cache/cb/cb1c15ce617fd7e7648d38bb0b2b48f5.php:250 msgid "Help" msgstr "" -#: cache/6e/6e8678de8ffffc5ed3b935eb9c3f6eb0.php:349 +#: cache/6e/6e8678de8ffffc5ed3b935eb9c3f6eb0.php:337 msgid "" "We are here to help! All the support you’re looking for, at your fingertips." msgstr "" -#: cache/6e/6e8678de8ffffc5ed3b935eb9c3f6eb0.php:356 +#: cache/6e/6e8678de8ffffc5ed3b935eb9c3f6eb0.php:344 msgid "Help Center" msgstr "" -#: cache/6e/6e8678de8ffffc5ed3b935eb9c3f6eb0.php:363 +#: cache/6e/6e8678de8ffffc5ed3b935eb9c3f6eb0.php:351 msgid "Contact your service provider for assistance." msgstr "" @@ -14052,7 +14061,7 @@ msgstr "" #: cache/17/1730b41c1b94639a7bb894f0f6bbe405.php:68 #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:807 -#: lib/Controller/Layout.php:1835 lib/Controller/Layout.php:1841 +#: lib/Controller/Layout.php:1836 lib/Controller/Layout.php:1842 msgid "Retire" msgstr "" @@ -14526,33 +14535,33 @@ msgstr "" msgid "%dataSetName% - Data Connector" msgstr "" -#: cache/66/664c6c931a53dbf67110ec2cb80223cf.php:112 +#: cache/66/664c6c931a53dbf67110ec2cb80223cf.php:117 msgid "Data Connector JavaScript" msgstr "" -#: cache/66/664c6c931a53dbf67110ec2cb80223cf.php:146 +#: cache/66/664c6c931a53dbf67110ec2cb80223cf.php:151 msgid "Test Params" msgstr "" -#: cache/66/664c6c931a53dbf67110ec2cb80223cf.php:162 +#: cache/66/664c6c931a53dbf67110ec2cb80223cf.php:167 msgid "DataSet Data" msgstr "" -#: cache/66/664c6c931a53dbf67110ec2cb80223cf.php:170 +#: cache/66/664c6c931a53dbf67110ec2cb80223cf.php:175 msgid "Other Data" msgstr "" -#: cache/66/664c6c931a53dbf67110ec2cb80223cf.php:178 +#: cache/66/664c6c931a53dbf67110ec2cb80223cf.php:183 msgid "Schedule Criteria" msgstr "" -#: cache/66/664c6c931a53dbf67110ec2cb80223cf.php:187 +#: cache/66/664c6c931a53dbf67110ec2cb80223cf.php:192 msgid "" "You can test passing parameters that would otherwise be set when this Data " "Connector is scheduled." msgstr "" -#: cache/66/664c6c931a53dbf67110ec2cb80223cf.php:192 +#: cache/66/664c6c931a53dbf67110ec2cb80223cf.php:197 msgid "Test Parameters" msgstr "" @@ -14780,7 +14789,7 @@ msgstr "" #: cache/40/4029f5db3a11dd01464e9160f8b6cf6f.php:72 #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:815 -#: lib/Controller/Template.php:192 lib/Controller/Layout.php:1696 +#: lib/Controller/Template.php:192 lib/Controller/Layout.php:1697 msgid "Discard" msgstr "" @@ -15250,7 +15259,7 @@ msgid "Template ID" msgstr "" #: cache/97/97f2c018825ccdd326aad4a0b160725e.php:266 -#: cache/1b/1b4d10b8bb67ce8b30ada153b15daf6e.php:734 +#: cache/1b/1b4d10b8bb67ce8b30ada153b15daf6e.php:714 #: cache/c5/c5ca1344d07a74a91c5de3a21d4c2de4.php:366 #: cache/08/08aee2625ba2279e6a2c6465471b11e8.php:248 #: cache/37/37f12704cf17017038d73d9ee1907d04.php:408 @@ -15262,7 +15271,7 @@ msgstr "" #: cache/b2/b21687508ff5b0bb637fdbd3f8c0d3c6.php:398 #: lib/XTR/MaintenanceDailyTask.php:157 lib/XTR/NotificationTidyTask.php:115 #: lib/XTR/MaintenanceRegularTask.php:150 lib/XTR/StatsArchiveTask.php:134 -#: lib/XTR/AuditLogArchiveTask.php:125 lib/XTR/RemoteDataSetFetchTask.php:214 +#: lib/XTR/AuditLogArchiveTask.php:125 lib/XTR/RemoteDataSetFetchTask.php:228 msgid "Done" msgstr "" @@ -15472,7 +15481,7 @@ msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:811 #: cache/1b/1b4d10b8bb67ce8b30ada153b15daf6e.php:163 #: cache/8d/8d27a7617bf5bf7e7c28a56a7b503514.php:74 -#: lib/Controller/Template.php:186 lib/Controller/Layout.php:1690 +#: lib/Controller/Template.php:186 lib/Controller/Layout.php:1691 msgid "Publish" msgstr "" @@ -15999,7 +16008,7 @@ msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:1527 #: cache/0c/0cc104a3d7987780eed7a70657ece580.php:131 -msgid "No Image set, add from Toolbar or Upload!" +msgid "No Image set, add from Toolbox or Upload!" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:1531 @@ -17025,7 +17034,7 @@ msgid "" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3307 -#: lib/Connector/OpenWeatherMapConnector.php:504 +#: lib/Connector/OpenWeatherMapConnector.php:519 msgid "Afrikaans" msgstr "" @@ -17054,12 +17063,12 @@ msgid "Arabic (Tunisia)" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3335 -#: lib/Connector/OpenWeatherMapConnector.php:505 +#: lib/Connector/OpenWeatherMapConnector.php:520 msgid "Arabic" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3339 -#: lib/Connector/OpenWeatherMapConnector.php:506 +#: lib/Connector/OpenWeatherMapConnector.php:521 msgid "Azerbaijani" msgstr "" @@ -17068,7 +17077,7 @@ msgid "Belarusian" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3347 -#: lib/Connector/OpenWeatherMapConnector.php:507 +#: lib/Connector/OpenWeatherMapConnector.php:522 msgid "Bulgarian" msgstr "" @@ -17097,12 +17106,12 @@ msgid "Bosnian" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3375 -#: lib/Connector/OpenWeatherMapConnector.php:508 +#: lib/Connector/OpenWeatherMapConnector.php:523 msgid "Catalan" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3379 -#: lib/Connector/OpenWeatherMapConnector.php:511 +#: lib/Connector/OpenWeatherMapConnector.php:526 msgid "Czech" msgstr "" @@ -17115,7 +17124,7 @@ msgid "Welsh" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3391 -#: lib/Connector/OpenWeatherMapConnector.php:512 +#: lib/Connector/OpenWeatherMapConnector.php:527 msgid "Danish" msgstr "" @@ -17128,7 +17137,7 @@ msgid "German (Switzerland)" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3403 -#: lib/Connector/OpenWeatherMapConnector.php:513 +#: lib/Connector/OpenWeatherMapConnector.php:528 msgid "German" msgstr "" @@ -17137,12 +17146,12 @@ msgid "Divehi" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3411 -#: lib/Connector/OpenWeatherMapConnector.php:514 +#: lib/Connector/OpenWeatherMapConnector.php:529 msgid "Greek" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3415 -#: lib/Connector/OpenWeatherMapConnector.php:515 +#: lib/Connector/OpenWeatherMapConnector.php:530 msgid "English" msgstr "" @@ -17196,7 +17205,7 @@ msgid "Spanish (United States)" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3471 -#: lib/Connector/OpenWeatherMapConnector.php:542 +#: lib/Connector/OpenWeatherMapConnector.php:557 msgid "Spanish" msgstr "" @@ -17205,7 +17214,7 @@ msgid "Estonian" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3479 -#: lib/Connector/OpenWeatherMapConnector.php:516 +#: lib/Connector/OpenWeatherMapConnector.php:531 msgid "Basque" msgstr "" @@ -17214,7 +17223,7 @@ msgid "Persian" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3487 -#: lib/Connector/OpenWeatherMapConnector.php:518 +#: lib/Connector/OpenWeatherMapConnector.php:533 msgid "Finnish" msgstr "" @@ -17235,7 +17244,7 @@ msgid "French (Switzerland)" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3507 -#: lib/Connector/OpenWeatherMapConnector.php:519 +#: lib/Connector/OpenWeatherMapConnector.php:534 msgid "French" msgstr "" @@ -17248,7 +17257,7 @@ msgid "Scottish Gaelic" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3519 -#: lib/Connector/OpenWeatherMapConnector.php:520 +#: lib/Connector/OpenWeatherMapConnector.php:535 msgid "Galician" msgstr "" @@ -17261,22 +17270,22 @@ msgid "Gujarati" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3531 -#: lib/Connector/OpenWeatherMapConnector.php:521 +#: lib/Connector/OpenWeatherMapConnector.php:536 msgid "Hebrew" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3535 -#: lib/Connector/OpenWeatherMapConnector.php:522 +#: lib/Connector/OpenWeatherMapConnector.php:537 msgid "Hindi" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3539 -#: lib/Connector/OpenWeatherMapConnector.php:523 +#: lib/Connector/OpenWeatherMapConnector.php:538 msgid "Croatian" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3543 -#: lib/Connector/OpenWeatherMapConnector.php:524 +#: lib/Connector/OpenWeatherMapConnector.php:539 msgid "Hungarian" msgstr "" @@ -17285,7 +17294,7 @@ msgid "Armenian (Armenia)" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3551 -#: lib/Connector/OpenWeatherMapConnector.php:525 +#: lib/Connector/OpenWeatherMapConnector.php:540 msgid "Indonesian" msgstr "" @@ -17298,12 +17307,12 @@ msgid "Italian (Switzerland)" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3563 -#: lib/Connector/OpenWeatherMapConnector.php:526 +#: lib/Connector/OpenWeatherMapConnector.php:541 msgid "Italian" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3567 -#: lib/Connector/OpenWeatherMapConnector.php:527 +#: lib/Connector/OpenWeatherMapConnector.php:542 msgid "Japanese" msgstr "" @@ -17328,7 +17337,7 @@ msgid "Kannada" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3591 -#: lib/Connector/OpenWeatherMapConnector.php:528 +#: lib/Connector/OpenWeatherMapConnector.php:543 msgid "Korean" msgstr "" @@ -17349,12 +17358,12 @@ msgid "Lao" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3611 -#: lib/Connector/OpenWeatherMapConnector.php:530 +#: lib/Connector/OpenWeatherMapConnector.php:545 msgid "Lithuanian" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3615 -#: lib/Connector/OpenWeatherMapConnector.php:529 +#: lib/Connector/OpenWeatherMapConnector.php:544 msgid "Latvian" msgstr "" @@ -17367,7 +17376,7 @@ msgid "Maori" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3627 -#: lib/Connector/OpenWeatherMapConnector.php:531 +#: lib/Connector/OpenWeatherMapConnector.php:546 msgid "Macedonian" msgstr "" @@ -17412,7 +17421,7 @@ msgid "Dutch (Belgium)" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3671 -#: lib/Connector/OpenWeatherMapConnector.php:533 +#: lib/Connector/OpenWeatherMapConnector.php:548 msgid "Dutch" msgstr "" @@ -17425,7 +17434,7 @@ msgid "Punjabi (India)" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3683 -#: lib/Connector/OpenWeatherMapConnector.php:534 +#: lib/Connector/OpenWeatherMapConnector.php:549 msgid "Polish" msgstr "" @@ -17434,17 +17443,17 @@ msgid "Portuguese (Brazil)" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3691 -#: lib/Connector/OpenWeatherMapConnector.php:535 +#: lib/Connector/OpenWeatherMapConnector.php:550 msgid "Portuguese" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3695 -#: lib/Connector/OpenWeatherMapConnector.php:537 +#: lib/Connector/OpenWeatherMapConnector.php:552 msgid "Romanian" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3699 -#: lib/Connector/OpenWeatherMapConnector.php:538 +#: lib/Connector/OpenWeatherMapConnector.php:553 msgid "Russian" msgstr "" @@ -17461,12 +17470,12 @@ msgid "Sinhala" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3715 -#: lib/Connector/OpenWeatherMapConnector.php:540 +#: lib/Connector/OpenWeatherMapConnector.php:555 msgid "Slovak" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3719 -#: lib/Connector/OpenWeatherMapConnector.php:541 +#: lib/Connector/OpenWeatherMapConnector.php:556 msgid "Slovenian" msgstr "" @@ -17479,7 +17488,7 @@ msgid "Serbian (Cyrillic)" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3731 -#: lib/Connector/OpenWeatherMapConnector.php:543 +#: lib/Connector/OpenWeatherMapConnector.php:558 msgid "Serbian" msgstr "" @@ -17488,7 +17497,7 @@ msgid "Swati" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3739 -#: lib/Connector/OpenWeatherMapConnector.php:539 +#: lib/Connector/OpenWeatherMapConnector.php:554 msgid "Swedish" msgstr "" @@ -17513,7 +17522,7 @@ msgid "Tajik" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3763 -#: lib/Connector/OpenWeatherMapConnector.php:544 +#: lib/Connector/OpenWeatherMapConnector.php:559 msgid "Thai" msgstr "" @@ -17530,7 +17539,7 @@ msgid "Klingon" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3779 -#: lib/Connector/OpenWeatherMapConnector.php:545 +#: lib/Connector/OpenWeatherMapConnector.php:560 msgid "Turkish" msgstr "" @@ -17551,7 +17560,7 @@ msgid "Uyghur (China)" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3799 -#: lib/Connector/OpenWeatherMapConnector.php:546 +#: lib/Connector/OpenWeatherMapConnector.php:561 msgid "Ukrainian" msgstr "" @@ -17568,7 +17577,7 @@ msgid "Uzbek" msgstr "" #: cache/74/744308e2456d1f83f205ef9ab9fe3022.php:3815 -#: lib/Connector/OpenWeatherMapConnector.php:547 +#: lib/Connector/OpenWeatherMapConnector.php:562 msgid "Vietnamese" msgstr "" @@ -17893,7 +17902,7 @@ msgstr "" #: cache/1b/1b4d10b8bb67ce8b30ada153b15daf6e.php:179 #: cache/ca/ca2dd0b2142f7bc07fc5e4b9d6a35375.php:72 -#: lib/Controller/Template.php:203 lib/Controller/Layout.php:1707 +#: lib/Controller/Template.php:203 lib/Controller/Layout.php:1708 msgid "Checkout" msgstr "" @@ -17902,7 +17911,7 @@ msgid "Unlock" msgstr "" #: cache/1b/1b4d10b8bb67ce8b30ada153b15daf6e.php:191 -#: lib/Controller/Layout.php:1896 +#: lib/Controller/Layout.php:1897 msgid "Save Template" msgstr "" @@ -18094,55 +18103,43 @@ msgid "Region Name" msgstr "" #: cache/1b/1b4d10b8bb67ce8b30ada153b15daf6e.php:504 -msgid "Add a Region" -msgstr "" - -#: cache/1b/1b4d10b8bb67ce8b30ada153b15daf6e.php:514 msgid "Upload Audio files to assign to Widgets" msgstr "" -#: cache/1b/1b4d10b8bb67ce8b30ada153b15daf6e.php:520 -msgid "Expiry Dates" -msgstr "" - -#: cache/1b/1b4d10b8bb67ce8b30ada153b15daf6e.php:524 -msgid "Enter start and end times for Widgets" -msgstr "" - -#: cache/1b/1b4d10b8bb67ce8b30ada153b15daf6e.php:530 +#: cache/1b/1b4d10b8bb67ce8b30ada153b15daf6e.php:510 msgid "Transition In" msgstr "" -#: cache/1b/1b4d10b8bb67ce8b30ada153b15daf6e.php:534 +#: cache/1b/1b4d10b8bb67ce8b30ada153b15daf6e.php:514 msgid "Apply a Transition type for the start of a media item" msgstr "" -#: cache/1b/1b4d10b8bb67ce8b30ada153b15daf6e.php:540 +#: cache/1b/1b4d10b8bb67ce8b30ada153b15daf6e.php:520 msgid "Transition Out" msgstr "" -#: cache/1b/1b4d10b8bb67ce8b30ada153b15daf6e.php:544 +#: cache/1b/1b4d10b8bb67ce8b30ada153b15daf6e.php:524 msgid "Apply a Transition type for the end of a media item" msgstr "" -#: cache/1b/1b4d10b8bb67ce8b30ada153b15daf6e.php:554 +#: cache/1b/1b4d10b8bb67ce8b30ada153b15daf6e.php:534 msgid "Set View, Edit and Delete Sharing for Widgets and Playlists" msgstr "" -#: cache/1b/1b4d10b8bb67ce8b30ada153b15daf6e.php:727 +#: cache/1b/1b4d10b8bb67ce8b30ada153b15daf6e.php:707 msgid "Add Background Image" msgstr "" -#: cache/1b/1b4d10b8bb67ce8b30ada153b15daf6e.php:747 +#: cache/1b/1b4d10b8bb67ce8b30ada153b15daf6e.php:727 msgid "Browse/Add Image" msgstr "" -#: cache/1b/1b4d10b8bb67ce8b30ada153b15daf6e.php:751 +#: cache/1b/1b4d10b8bb67ce8b30ada153b15daf6e.php:731 #: cache/c5/c5ca1344d07a74a91c5de3a21d4c2de4.php:383 msgid "Start Upload" msgstr "" -#: cache/1b/1b4d10b8bb67ce8b30ada153b15daf6e.php:755 +#: cache/1b/1b4d10b8bb67ce8b30ada153b15daf6e.php:735 #: cache/c5/c5ca1344d07a74a91c5de3a21d4c2de4.php:387 msgid "Cancel Upload" msgstr "" @@ -20111,12 +20108,12 @@ msgstr "" #: cache/37/37f12704cf17017038d73d9ee1907d04.php:444 #: lib/Controller/Template.php:301 lib/Controller/Template.php:314 #: lib/Controller/DisplayGroup.php:429 lib/Controller/DisplayGroup.php:442 -#: lib/Controller/DataSet.php:348 lib/Controller/DataSet.php:354 +#: lib/Controller/DataSet.php:350 lib/Controller/DataSet.php:356 #: lib/Controller/Campaign.php:422 lib/Controller/Campaign.php:428 #: lib/Controller/MenuBoard.php:194 lib/Controller/MenuBoard.php:202 #: lib/Controller/Playlist.php:492 lib/Controller/Playlist.php:504 #: lib/Controller/Library.php:713 lib/Controller/Library.php:719 -#: lib/Controller/Layout.php:1915 lib/Controller/Layout.php:1921 +#: lib/Controller/Layout.php:1916 lib/Controller/Layout.php:1922 #: lib/Controller/Display.php:1145 lib/Controller/Display.php:1158 #: lib/Controller/Developer.php:133 lib/Controller/Developer.php:146 #: lib/Controller/Command.php:213 lib/Controller/Command.php:226 @@ -21575,21 +21572,21 @@ msgstr "" msgid "CSV Import" msgstr "" -#: cache/b2/b21687508ff5b0bb637fdbd3f8c0d3c6.php:650 +#: cache/b2/b21687508ff5b0bb637fdbd3f8c0d3c6.php:673 msgid "Overwrite existing data?" msgstr "" -#: cache/b2/b21687508ff5b0bb637fdbd3f8c0d3c6.php:655 +#: cache/b2/b21687508ff5b0bb637fdbd3f8c0d3c6.php:678 msgid "" "Erase all content in this DataSet and overwrite it with the new content in " "this import." msgstr "" -#: cache/b2/b21687508ff5b0bb637fdbd3f8c0d3c6.php:670 +#: cache/b2/b21687508ff5b0bb637fdbd3f8c0d3c6.php:693 msgid "Ignore the first row? Useful if the CSV has headings." msgstr "" -#: cache/b2/b21687508ff5b0bb637fdbd3f8c0d3c6.php:680 +#: cache/b2/b21687508ff5b0bb637fdbd3f8c0d3c6.php:703 msgid "" "In the fields below please enter the column number in the CSV file that " "corresponds to the Column Heading listed. This should be done before Adding " @@ -21649,7 +21646,7 @@ msgstr "" #: lib/Controller/Task.php:215 lib/Controller/Widget.php:300 #: lib/Controller/Template.php:586 lib/Controller/DisplayGroup.php:797 -#: lib/Controller/DisplayGroup.php:2665 lib/Controller/DataSet.php:679 +#: lib/Controller/DisplayGroup.php:2665 lib/Controller/DataSet.php:698 #: lib/Controller/Notification.php:639 lib/Controller/Campaign.php:649 #: lib/Controller/Campaign.php:1385 lib/Controller/Folder.php:263 #: lib/Controller/Playlist.php:791 lib/Controller/SyncGroup.php:293 @@ -21665,15 +21662,15 @@ msgid "Added %s" msgstr "" #: lib/Controller/Task.php:284 lib/Controller/Widget.php:587 -#: lib/Controller/DisplayGroup.php:1026 lib/Controller/DataSet.php:991 -#: lib/Controller/DataSet.php:1053 lib/Controller/ScheduleReport.php:426 +#: lib/Controller/DisplayGroup.php:1026 lib/Controller/DataSet.php:1029 +#: lib/Controller/DataSet.php:1091 lib/Controller/ScheduleReport.php:426 #: lib/Controller/Notification.php:761 lib/Controller/Campaign.php:959 #: lib/Controller/MenuBoard.php:446 lib/Controller/Folder.php:331 #: lib/Controller/Playlist.php:982 lib/Controller/Transition.php:158 #: lib/Controller/Library.php:1425 lib/Controller/SyncGroup.php:575 #: lib/Controller/DataSetColumn.php:596 lib/Controller/Tag.php:545 #: lib/Controller/Connector.php:205 lib/Controller/Layout.php:669 -#: lib/Controller/Layout.php:791 lib/Controller/Layout.php:935 +#: lib/Controller/Layout.php:791 lib/Controller/Layout.php:936 #: lib/Controller/MenuBoardCategory.php:437 #: lib/Controller/DisplayProfile.php:485 lib/Controller/User.php:922 #: lib/Controller/User.php:2549 lib/Controller/Applications.php:560 @@ -21688,13 +21685,13 @@ msgid "Edited %s" msgstr "" #: lib/Controller/Task.php:331 lib/Controller/Widget.php:706 -#: lib/Controller/DisplayGroup.php:1082 lib/Controller/DataSet.php:1143 +#: lib/Controller/DisplayGroup.php:1082 lib/Controller/DataSet.php:1181 #: lib/Controller/ScheduleReport.php:464 lib/Controller/Notification.php:820 #: lib/Controller/Campaign.php:1038 lib/Controller/SavedReport.php:283 #: lib/Controller/MenuBoard.php:523 lib/Controller/Folder.php:413 #: lib/Controller/Playlist.php:1063 lib/Controller/Library.php:1035 #: lib/Controller/SyncGroup.php:653 lib/Controller/DataSetColumn.php:685 -#: lib/Controller/Tag.php:630 lib/Controller/Layout.php:1049 +#: lib/Controller/Tag.php:630 lib/Controller/Layout.php:1050 #: lib/Controller/MenuBoardCategory.php:517 #: lib/Controller/DisplayProfile.php:564 lib/Controller/User.php:1039 #: lib/Controller/Applications.php:593 lib/Controller/Resolution.php:442 @@ -21805,7 +21802,7 @@ msgid "Deleted Widget" msgstr "" #: lib/Controller/Widget.php:747 lib/Controller/Region.php:690 -#: lib/Connector/OpenWeatherMapConnector.php:677 +#: lib/Connector/OpenWeatherMapConnector.php:692 msgid "North" msgstr "" @@ -21814,7 +21811,7 @@ msgid "North East" msgstr "" #: lib/Controller/Widget.php:749 lib/Controller/Region.php:692 -#: lib/Connector/OpenWeatherMapConnector.php:681 +#: lib/Connector/OpenWeatherMapConnector.php:696 msgid "East" msgstr "" @@ -21823,7 +21820,7 @@ msgid "South East" msgstr "" #: lib/Controller/Widget.php:751 lib/Controller/Region.php:694 -#: lib/Connector/OpenWeatherMapConnector.php:685 +#: lib/Connector/OpenWeatherMapConnector.php:700 msgid "South" msgstr "" @@ -21832,7 +21829,7 @@ msgid "South West" msgstr "" #: lib/Controller/Widget.php:753 lib/Controller/Region.php:696 -#: lib/Connector/OpenWeatherMapConnector.php:689 +#: lib/Connector/OpenWeatherMapConnector.php:704 msgid "West" msgstr "" @@ -21921,9 +21918,9 @@ msgid "Alter Template" msgstr "" #: lib/Controller/Template.php:246 lib/Controller/DisplayGroup.php:361 -#: lib/Controller/DataSet.php:289 lib/Controller/Campaign.php:361 +#: lib/Controller/DataSet.php:291 lib/Controller/Campaign.php:361 #: lib/Controller/MenuBoard.php:179 lib/Controller/Playlist.php:422 -#: lib/Controller/Library.php:677 lib/Controller/Layout.php:1816 +#: lib/Controller/Library.php:677 lib/Controller/Layout.php:1817 #: lib/Controller/Display.php:957 msgid "Move to Folder" msgstr "" @@ -22077,77 +22074,77 @@ msgstr "" msgid "Please provide a Trigger Code" msgstr "" -#: lib/Controller/DataSet.php:232 +#: lib/Controller/DataSet.php:234 msgid "View RSS" msgstr "" -#: lib/Controller/DataSet.php:242 +#: lib/Controller/DataSet.php:244 msgid "View Data Connector" msgstr "" -#: lib/Controller/DataSet.php:254 +#: lib/Controller/DataSet.php:256 msgid "Import CSV" msgstr "" -#: lib/Controller/DataSet.php:300 +#: lib/Controller/DataSet.php:302 msgid "Export (CSV)" msgstr "" -#: lib/Controller/DataSet.php:1048 +#: lib/Controller/DataSet.php:1086 msgid "This DataSet does not have a data connector" msgstr "" -#: lib/Controller/DataSet.php:1081 lib/Entity/DataSet.php:1019 +#: lib/Controller/DataSet.php:1119 lib/Entity/DataSet.php:1025 msgid "Lookup Tables cannot be deleted" msgstr "" -#: lib/Controller/DataSet.php:1135 +#: lib/Controller/DataSet.php:1173 msgid "There is data assigned to this data set, cannot delete." msgstr "" -#: lib/Controller/DataSet.php:1244 +#: lib/Controller/DataSet.php:1282 #, php-format msgid "DataSet %s moved to Folder %s" msgstr "" -#: lib/Controller/DataSet.php:1368 +#: lib/Controller/DataSet.php:1406 #, php-format msgid "Copied %s as %s" msgstr "" -#: lib/Controller/DataSet.php:1526 +#: lib/Controller/DataSet.php:1564 msgid "Missing JSON Body" msgstr "" -#: lib/Controller/DataSet.php:1533 +#: lib/Controller/DataSet.php:1571 msgid "Malformed JSON body, rows and uniqueKeys are required" msgstr "" -#: lib/Controller/DataSet.php:1573 +#: lib/Controller/DataSet.php:1611 #, php-format msgid "Incorrect date provided %s, expected date format Y-m-d H:i:s " msgstr "" -#: lib/Controller/DataSet.php:1627 +#: lib/Controller/DataSet.php:1665 msgid "No data found in request body" msgstr "" -#: lib/Controller/DataSet.php:1631 +#: lib/Controller/DataSet.php:1669 #, php-format msgid "Imported JSON into %s" msgstr "" -#: lib/Controller/DataSet.php:1695 +#: lib/Controller/DataSet.php:1733 #, php-format msgid "Run Test-Request for %s" msgstr "" -#: lib/Controller/DataSet.php:1810 +#: lib/Controller/DataSet.php:1848 #, php-format msgid "Cache cleared for %s" msgstr "" -#: lib/Controller/DataSet.php:1897 +#: lib/Controller/DataSet.php:1945 msgid "URL not found in data connector script" msgstr "" @@ -22398,7 +22395,7 @@ msgid "Please provide LayoutId" msgstr "" #: lib/Controller/Action.php:348 lib/Controller/Action.php:498 -#: lib/Controller/Action.php:562 lib/Controller/Layout.php:2991 +#: lib/Controller/Action.php:562 lib/Controller/Layout.php:2992 msgid "Layout is not checked out" msgstr "" @@ -22549,7 +22546,7 @@ msgstr "" #: lib/Controller/Playlist.php:433 lib/Controller/Playlist.php:444 #: lib/Controller/Library.php:748 lib/Controller/Library.php:754 -#: lib/Controller/Layout.php:1877 lib/Controller/Layout.php:1883 +#: lib/Controller/Layout.php:1878 lib/Controller/Layout.php:1884 msgid "Enable stats collection?" msgstr "" @@ -22568,7 +22565,7 @@ msgid "" msgstr "" #: lib/Controller/Playlist.php:1204 lib/Controller/Library.php:2294 -#: lib/Controller/Layout.php:2207 lib/Controller/Developer.php:635 +#: lib/Controller/Layout.php:2208 lib/Controller/Developer.php:635 #, php-format msgid "Copied as %s" msgstr "" @@ -22608,7 +22605,7 @@ msgid "Specified Playlist item is not in use." msgstr "" #: lib/Controller/Playlist.php:1717 lib/Controller/Library.php:2153 -#: lib/Controller/Layout.php:1727 +#: lib/Controller/Layout.php:1728 msgid "Preview Layout" msgstr "" @@ -22710,20 +22707,20 @@ msgstr "" msgid "Route is available through the API" msgstr "" -#: lib/Controller/Library.php:1843 lib/Controller/Layout.php:2272 +#: lib/Controller/Library.php:1843 lib/Controller/Layout.php:2273 msgid "No tags to assign" msgstr "" -#: lib/Controller/Library.php:1854 lib/Controller/Layout.php:2283 +#: lib/Controller/Library.php:1854 lib/Controller/Layout.php:2284 #, php-format msgid "Tagged %s" msgstr "" -#: lib/Controller/Library.php:1917 lib/Controller/Layout.php:2348 +#: lib/Controller/Library.php:1917 lib/Controller/Layout.php:2349 msgid "No tags to unassign" msgstr "" -#: lib/Controller/Library.php:1928 lib/Controller/Layout.php:2358 +#: lib/Controller/Library.php:1928 lib/Controller/Layout.php:2359 #, php-format msgid "Untagged %s" msgstr "" @@ -22834,167 +22831,167 @@ msgid "" "Template button instead." msgstr "" -#: lib/Controller/Layout.php:959 lib/Controller/Layout.php:1036 +#: lib/Controller/Layout.php:960 lib/Controller/Layout.php:1037 msgid "You do not have permissions to delete this layout" msgstr "" -#: lib/Controller/Layout.php:988 lib/Controller/Layout.php:1090 -#: lib/Controller/Layout.php:1138 lib/Controller/Layout.php:1187 -#: lib/Controller/Layout.php:1256 lib/Controller/Layout.php:1295 -#: lib/Controller/Layout.php:2717 lib/Controller/Layout.php:2768 -#: lib/Controller/Layout.php:2807 lib/Controller/Layout.php:2876 -#: lib/Controller/Layout.php:2936 lib/Controller/Layout.php:2986 +#: lib/Controller/Layout.php:989 lib/Controller/Layout.php:1091 +#: lib/Controller/Layout.php:1139 lib/Controller/Layout.php:1188 +#: lib/Controller/Layout.php:1257 lib/Controller/Layout.php:1296 +#: lib/Controller/Layout.php:2718 lib/Controller/Layout.php:2769 +#: lib/Controller/Layout.php:2808 lib/Controller/Layout.php:2877 +#: lib/Controller/Layout.php:2937 lib/Controller/Layout.php:2987 msgid "You do not have permissions to edit this layout" msgstr "" -#: lib/Controller/Layout.php:1041 +#: lib/Controller/Layout.php:1042 msgid "Cannot delete Layout from its Draft, delete the parent" msgstr "" -#: lib/Controller/Layout.php:1095 lib/Controller/Layout.php:1192 -#: lib/Controller/Layout.php:1261 +#: lib/Controller/Layout.php:1096 lib/Controller/Layout.php:1193 +#: lib/Controller/Layout.php:1262 msgid "Cannot modify Layout from its Draft" msgstr "" -#: lib/Controller/Layout.php:1100 +#: lib/Controller/Layout.php:1101 msgid "This Layout is used as the global default and cannot be retired" msgstr "" -#: lib/Controller/Layout.php:1115 +#: lib/Controller/Layout.php:1116 #, php-format msgid "Retired %s" msgstr "" -#: lib/Controller/Layout.php:1207 +#: lib/Controller/Layout.php:1208 #, php-format msgid "Unretired %s" msgstr "" -#: lib/Controller/Layout.php:1272 +#: lib/Controller/Layout.php:1273 #, php-format msgid "For Layout %s Enable Stats Collection is set to %s" msgstr "" -#: lib/Controller/Layout.php:1510 lib/Controller/Layout.php:1511 +#: lib/Controller/Layout.php:1511 lib/Controller/Layout.php:1512 msgid "Invalid Module" msgstr "" -#: lib/Controller/Layout.php:1653 lib/Controller/Layout.php:2410 +#: lib/Controller/Layout.php:1654 lib/Controller/Layout.php:2411 msgid "This Layout is ready to play" msgstr "" -#: lib/Controller/Layout.php:1654 lib/Controller/Layout.php:2414 +#: lib/Controller/Layout.php:1655 lib/Controller/Layout.php:2415 msgid "There are items on this Layout that can only be assessed by the Display" msgstr "" -#: lib/Controller/Layout.php:1655 lib/Controller/Layout.php:2418 +#: lib/Controller/Layout.php:1656 lib/Controller/Layout.php:2419 msgid "This Layout has not been built yet" msgstr "" -#: lib/Controller/Layout.php:1656 lib/Controller/Layout.php:2422 +#: lib/Controller/Layout.php:1657 lib/Controller/Layout.php:2423 msgid "This Layout is invalid and should not be scheduled" msgstr "" -#: lib/Controller/Layout.php:1660 +#: lib/Controller/Layout.php:1661 msgid "This Layout has enable stat collection set to ON" msgstr "" -#: lib/Controller/Layout.php:1661 +#: lib/Controller/Layout.php:1662 msgid "This Layout has enable stat collection set to OFF" msgstr "" -#: lib/Controller/Layout.php:1738 +#: lib/Controller/Layout.php:1739 msgid "Preview Draft Layout" msgstr "" -#: lib/Controller/Layout.php:1759 +#: lib/Controller/Layout.php:1760 msgid "Assign to Campaign" msgstr "" -#: lib/Controller/Layout.php:1770 +#: lib/Controller/Layout.php:1771 msgid "Jump to Playlists included on this Layout" msgstr "" -#: lib/Controller/Layout.php:1779 +#: lib/Controller/Layout.php:1780 msgid "Jump to Campaigns containing this Layout" msgstr "" -#: lib/Controller/Layout.php:1788 +#: lib/Controller/Layout.php:1789 msgid "Jump to Media included on this Layout" msgstr "" -#: lib/Controller/Layout.php:1850 +#: lib/Controller/Layout.php:1851 msgid "Unretire" msgstr "" -#: lib/Controller/Layout.php:2121 +#: lib/Controller/Layout.php:2122 msgid "Cannot copy a Draft Layout" msgstr "" -#: lib/Controller/Layout.php:2267 lib/Controller/Layout.php:2343 +#: lib/Controller/Layout.php:2268 lib/Controller/Layout.php:2344 msgid "Cannot manage tags on a Draft Layout" msgstr "" -#: lib/Controller/Layout.php:2472 lib/Controller/Layout.php:2510 +#: lib/Controller/Layout.php:2473 lib/Controller/Layout.php:2511 msgid "Cannot export Draft Layout" msgstr "" -#: lib/Controller/Layout.php:2646 +#: lib/Controller/Layout.php:2647 msgid "Layout background must be an image" msgstr "" -#: lib/Controller/Layout.php:2773 +#: lib/Controller/Layout.php:2774 msgid "Layout is already checked out" msgstr "" -#: lib/Controller/Layout.php:2782 +#: lib/Controller/Layout.php:2783 #, php-format msgid "Checked out %s" msgstr "" -#: lib/Controller/Layout.php:2898 +#: lib/Controller/Layout.php:2899 #, php-format msgid "Published %s" msgstr "" -#: lib/Controller/Layout.php:2905 +#: lib/Controller/Layout.php:2906 #, php-format msgid "Layout will be published on %s" msgstr "" -#: lib/Controller/Layout.php:3003 +#: lib/Controller/Layout.php:3004 #, php-format msgid "Discarded %s" msgstr "" -#: lib/Controller/Layout.php:3054 +#: lib/Controller/Layout.php:3055 msgid "" "This function is available only to User who originally locked this Layout." msgstr "" -#: lib/Controller/Layout.php:3143 lib/Entity/Layout.php:2277 +#: lib/Controller/Layout.php:3144 lib/Entity/Layout.php:2277 msgid "Empty Region" msgstr "" -#: lib/Controller/Layout.php:3247 +#: lib/Controller/Layout.php:3248 msgid "Incorrect image data" msgstr "" -#: lib/Controller/Layout.php:3272 +#: lib/Controller/Layout.php:3273 msgid "Thumbnail not found for Layout" msgstr "" -#: lib/Controller/Layout.php:3364 +#: lib/Controller/Layout.php:3365 #, php-format msgid "Please select %s" msgstr "" -#: lib/Controller/Layout.php:3382 +#: lib/Controller/Layout.php:3383 #, php-format msgid "Fetched %s" msgstr "" -#: lib/Controller/Layout.php:3505 +#: lib/Controller/Layout.php:3506 #, php-format msgid "Created %s" msgstr "" @@ -23816,6 +23813,10 @@ msgstr "" msgid "Access to this route is denied for this scope" msgstr "" +#: lib/Event/DataConnectorSourceRequestEvent.php:49 +msgid "User-Defined JavaScript" +msgstr "" + #: lib/Event/ScheduleCriteriaRequestEvent.php:72 #: lib/Event/ScheduleCriteriaRequestEvent.php:110 msgid "Current type is not set." @@ -23853,7 +23854,7 @@ msgstr "" msgid "Please enter a display group name" msgstr "" -#: lib/Entity/DisplayGroup.php:589 lib/Entity/DataSet.php:865 +#: lib/Entity/DisplayGroup.php:589 lib/Entity/DataSet.php:871 #: lib/Entity/Layout.php:1095 msgid "Description can not be longer than 254 characters" msgstr "" @@ -23872,43 +23873,43 @@ msgstr "" msgid "This assignment creates a circular reference" msgstr "" -#: lib/Entity/DataSet.php:406 lib/Entity/DataSet.php:429 +#: lib/Entity/DataSet.php:412 lib/Entity/DataSet.php:435 #, php-format msgid "Column %s not found" msgstr "" -#: lib/Entity/DataSet.php:463 +#: lib/Entity/DataSet.php:469 msgid "Unknown Column " msgstr "" -#: lib/Entity/DataSet.php:861 +#: lib/Entity/DataSet.php:867 msgid "Name must be between 1 and 50 characters" msgstr "" -#: lib/Entity/DataSet.php:871 +#: lib/Entity/DataSet.php:877 msgid "A remote DataSet must have a URI." msgstr "" -#: lib/Entity/DataSet.php:875 +#: lib/Entity/DataSet.php:881 msgid "DataSet row limit cannot be larger than the CMS dataSet row limit" msgstr "" -#: lib/Entity/DataSet.php:883 +#: lib/Entity/DataSet.php:889 #, php-format msgid "There is already dataSet called %s. Please choose another name." msgstr "" -#: lib/Entity/DataSet.php:1029 +#: lib/Entity/DataSet.php:1035 msgid "" "Cannot delete because this DataSet is set as dependent DataSet for another " "DataSet" msgstr "" -#: lib/Entity/DataSet.php:1040 +#: lib/Entity/DataSet.php:1046 msgid "Cannot delete because DataSet is in use on one or more Layouts." msgstr "" -#: lib/Entity/DataSet.php:1049 +#: lib/Entity/DataSet.php:1055 msgid "" "Cannot delete because DataSet is in use on one or more Data Connector " "schedules." @@ -24099,36 +24100,36 @@ msgid "" "provide Tag value in the dedicated field." msgstr "" -#: lib/Entity/Media.php:291 +#: lib/Entity/Media.php:292 #, php-format msgid "Copy of %s on %s" msgstr "" -#: lib/Entity/Media.php:378 +#: lib/Entity/Media.php:379 msgid "Unknown Media Type" msgstr "" -#: lib/Entity/Media.php:382 +#: lib/Entity/Media.php:383 msgid "The name must be between 1 and 100 characters" msgstr "" -#: lib/Entity/Media.php:407 +#: lib/Entity/Media.php:408 msgid "Media you own already has this name. Please choose another." msgstr "" -#: lib/Entity/Media.php:724 +#: lib/Entity/Media.php:753 msgid "Problem copying file in the Library Folder" msgstr "" -#: lib/Entity/Media.php:730 +#: lib/Entity/Media.php:759 msgid "Problem moving uploaded file into the Library Folder" msgstr "" -#: lib/Entity/Media.php:748 +#: lib/Entity/Media.php:777 msgid "Problem moving downloaded file into the Library Folder" msgstr "" -#: lib/Entity/Media.php:755 +#: lib/Entity/Media.php:784 msgid "Problem copying provided file into the Library Folder" msgstr "" @@ -24624,12 +24625,12 @@ msgid "Class %s not found" msgstr "" #: lib/Service/ReportService.php:299 lib/Service/ReportService.php:336 -#: lib/Factory/LayoutFactory.php:1296 +#: lib/Factory/LayoutFactory.php:1294 msgid "File does not exist" msgstr "" #: lib/Service/ReportService.php:305 lib/Service/ReportService.php:342 -#: lib/Factory/LayoutFactory.php:1302 +#: lib/Factory/LayoutFactory.php:1300 msgid "Unable to open ZIP" msgstr "" @@ -24928,60 +24929,60 @@ msgstr "" msgid "Cannot find Player Version" msgstr "" -#: lib/Factory/LayoutFactory.php:322 +#: lib/Factory/LayoutFactory.php:321 msgid "LayoutId is 0" msgstr "" -#: lib/Factory/LayoutFactory.php:328 lib/Factory/LayoutFactory.php:370 -#: lib/Factory/LayoutFactory.php:415 lib/Factory/LayoutFactory.php:436 -#: lib/Factory/LayoutFactory.php:491 lib/Factory/LayoutFactory.php:545 +#: lib/Factory/LayoutFactory.php:327 lib/Factory/LayoutFactory.php:369 +#: lib/Factory/LayoutFactory.php:414 lib/Factory/LayoutFactory.php:435 +#: lib/Factory/LayoutFactory.php:490 lib/Factory/LayoutFactory.php:544 msgid "Layout not found" msgstr "" -#: lib/Factory/LayoutFactory.php:345 lib/Factory/LayoutFactory.php:387 +#: lib/Factory/LayoutFactory.php:344 lib/Factory/LayoutFactory.php:386 msgid "Invalid Input" msgstr "" -#: lib/Factory/LayoutFactory.php:351 lib/Factory/LayoutFactory.php:393 +#: lib/Factory/LayoutFactory.php:350 lib/Factory/LayoutFactory.php:392 msgid "Layout does not exist" msgstr "" -#: lib/Factory/LayoutFactory.php:663 +#: lib/Factory/LayoutFactory.php:662 msgid "Layout import failed, invalid xlf supplied" msgstr "" -#: lib/Factory/LayoutFactory.php:1308 +#: lib/Factory/LayoutFactory.php:1306 msgid "Unable to read layout details from ZIP" msgstr "" -#: lib/Factory/LayoutFactory.php:1360 +#: lib/Factory/LayoutFactory.php:1358 msgid "" "Unsupported format. Missing Layout definitions from layout.json file in the " "archive." msgstr "" -#: lib/Factory/LayoutFactory.php:1484 +#: lib/Factory/LayoutFactory.php:1485 msgid "Empty file in ZIP" msgstr "" -#: lib/Factory/LayoutFactory.php:1490 +#: lib/Factory/LayoutFactory.php:1491 msgid "Cannot save media file from ZIP file" msgstr "" -#: lib/Factory/LayoutFactory.php:1851 +#: lib/Factory/LayoutFactory.php:1852 #, php-format msgid "DataSets have different number of columns imported = %d, existing = %d" msgstr "" -#: lib/Factory/LayoutFactory.php:1868 +#: lib/Factory/LayoutFactory.php:1869 msgid "DataSets have different column names" msgstr "" -#: lib/Factory/LayoutFactory.php:2761 lib/Factory/LayoutFactory.php:2774 +#: lib/Factory/LayoutFactory.php:2762 lib/Factory/LayoutFactory.php:2775 msgid "Draft" msgstr "" -#: lib/Factory/LayoutFactory.php:3097 +#: lib/Factory/LayoutFactory.php:3098 msgid "Module not found" msgstr "" @@ -25118,50 +25119,50 @@ msgstr "" msgid "Menu Board not found" msgstr "" -#: lib/Factory/DataSetFactory.php:447 +#: lib/Factory/DataSetFactory.php:448 #, php-format msgid "" "The request %d is too large to fit inside the configured memory limit. %d" msgstr "" -#: lib/Factory/DataSetFactory.php:517 +#: lib/Factory/DataSetFactory.php:518 #, php-format msgid "Unable to get Data for %s because the response was not valid JSON." msgstr "" -#: lib/Factory/DataSetFactory.php:551 +#: lib/Factory/DataSetFactory.php:560 #, php-format msgid "Unable to get Data for %s because %s." msgstr "" -#: lib/Factory/DataSetFactory.php:599 +#: lib/Factory/DataSetFactory.php:608 #, php-format msgid "Processing %d results into %d potential columns" msgstr "" -#: lib/Factory/DataSetFactory.php:602 +#: lib/Factory/DataSetFactory.php:611 #, php-format msgid "Processing Result with Data Root %s" msgstr "" -#: lib/Factory/DataSetFactory.php:645 +#: lib/Factory/DataSetFactory.php:654 msgid "Processing as a Single Row" msgstr "" -#: lib/Factory/DataSetFactory.php:650 +#: lib/Factory/DataSetFactory.php:659 msgid "Processing as Multiple Rows" msgstr "" -#: lib/Factory/DataSetFactory.php:660 +#: lib/Factory/DataSetFactory.php:669 #, php-format msgid "No data found at the DataRoot %s" msgstr "" -#: lib/Factory/DataSetFactory.php:663 +#: lib/Factory/DataSetFactory.php:672 msgid "Consolidating entries" msgstr "" -#: lib/Factory/DataSetFactory.php:668 +#: lib/Factory/DataSetFactory.php:677 #, php-format msgid "There are %d entries in total" msgstr "" @@ -25692,7 +25693,7 @@ msgstr "" msgid "Entity not found: " msgstr "" -#: lib/Factory/PermissionFactory.php:180 +#: lib/Factory/PermissionFactory.php:176 msgid "Entity not found" msgstr "" @@ -25757,53 +25758,58 @@ msgstr "" msgid "%s must be a valid URI" msgstr "" -#: lib/Widget/Definition/Property.php:289 -#: lib/Widget/Definition/Property.php:301 +#: lib/Widget/Definition/Property.php:292 +#, php-format +msgid "%s must be a valid Windows path" +msgstr "" + +#: lib/Widget/Definition/Property.php:306 +#: lib/Widget/Definition/Property.php:318 msgid "" "That is not a valid date interval, please use natural language such as 1 week" msgstr "" -#: lib/Widget/Definition/Property.php:311 +#: lib/Widget/Definition/Property.php:328 #, php-format msgid "%s must equal %s" msgstr "" -#: lib/Widget/Definition/Property.php:320 +#: lib/Widget/Definition/Property.php:337 #, php-format msgid "%s must not equal %s" msgstr "" -#: lib/Widget/Definition/Property.php:329 +#: lib/Widget/Definition/Property.php:346 #, php-format msgid "%s must contain %s" msgstr "" -#: lib/Widget/Definition/Property.php:339 +#: lib/Widget/Definition/Property.php:356 #, php-format msgid "%s must not contain %s" msgstr "" -#: lib/Widget/Definition/Property.php:350 +#: lib/Widget/Definition/Property.php:367 #, php-format msgid "%s must be less than %s" msgstr "" -#: lib/Widget/Definition/Property.php:361 +#: lib/Widget/Definition/Property.php:378 #, php-format msgid "%s must be less than or equal to %s" msgstr "" -#: lib/Widget/Definition/Property.php:372 +#: lib/Widget/Definition/Property.php:389 #, php-format msgid "%s must be greater than or equal to %s" msgstr "" -#: lib/Widget/Definition/Property.php:383 +#: lib/Widget/Definition/Property.php:400 #, php-format msgid "%s must be greater than %s" msgstr "" -#: lib/Widget/Definition/Property.php:455 +#: lib/Widget/Definition/Property.php:472 #, php-format msgid "%s is not a valid option" msgstr "" @@ -25886,24 +25892,24 @@ msgstr "" msgid "Data Connector %s not found" msgstr "" -#: lib/Xmds/Soap.php:585 +#: lib/Xmds/Soap.php:586 #, php-format msgid "" "Scheduled Action Event ID %d contains an invalid Layout linked to it by the " "Layout code." msgstr "" -#: lib/Xmds/Soap.php:2664 +#: lib/Xmds/Soap.php:2683 #, php-format msgid "Recovery for Display %s" msgstr "" -#: lib/Xmds/Soap.php:2666 +#: lib/Xmds/Soap.php:2685 #, php-format msgid "Display ID %d is now back online %s" msgstr "" -#: lib/Xmds/Soap.php:2806 +#: lib/Xmds/Soap.php:2825 msgid "Bandwidth allowance exceeded" msgstr "" @@ -25964,7 +25970,11 @@ msgstr "" msgid "Wake On Lan Failed as there are no functions available to transmit it" msgstr "" -#: lib/Helper/DataSetUploadHandler.php:133 +#: lib/Helper/DataSetUploadHandler.php:76 +msgid "Import failed: No value columns defined in the dataset." +msgstr "" + +#: lib/Helper/DataSetUploadHandler.php:153 #, php-format msgid "Unable to import row %d" msgstr "" @@ -26289,139 +26299,139 @@ msgstr "" msgid "Unable to get weather results." msgstr "" -#: lib/Connector/OpenWeatherMapConnector.php:509 +#: lib/Connector/OpenWeatherMapConnector.php:524 msgid "Chinese Simplified" msgstr "" -#: lib/Connector/OpenWeatherMapConnector.php:510 +#: lib/Connector/OpenWeatherMapConnector.php:525 msgid "Chinese Traditional" msgstr "" -#: lib/Connector/OpenWeatherMapConnector.php:517 +#: lib/Connector/OpenWeatherMapConnector.php:532 msgid "Persian (Farsi)" msgstr "" -#: lib/Connector/OpenWeatherMapConnector.php:532 +#: lib/Connector/OpenWeatherMapConnector.php:547 msgid "Norwegian" msgstr "" -#: lib/Connector/OpenWeatherMapConnector.php:536 +#: lib/Connector/OpenWeatherMapConnector.php:551 msgid "Português Brasil" msgstr "" -#: lib/Connector/OpenWeatherMapConnector.php:548 +#: lib/Connector/OpenWeatherMapConnector.php:563 msgid "Zulu" msgstr "" -#: lib/Connector/OpenWeatherMapConnector.php:653 +#: lib/Connector/OpenWeatherMapConnector.php:668 msgid "Weather Condition" msgstr "" -#: lib/Connector/OpenWeatherMapConnector.php:655 +#: lib/Connector/OpenWeatherMapConnector.php:670 msgid "Clear Sky" msgstr "" -#: lib/Connector/OpenWeatherMapConnector.php:656 +#: lib/Connector/OpenWeatherMapConnector.php:671 msgid "Few Clouds" msgstr "" -#: lib/Connector/OpenWeatherMapConnector.php:657 +#: lib/Connector/OpenWeatherMapConnector.php:672 msgid "Scattered Clouds" msgstr "" -#: lib/Connector/OpenWeatherMapConnector.php:658 +#: lib/Connector/OpenWeatherMapConnector.php:673 msgid "Broken Clouds" msgstr "" -#: lib/Connector/OpenWeatherMapConnector.php:659 +#: lib/Connector/OpenWeatherMapConnector.php:674 msgid "Shower Rain" msgstr "" -#: lib/Connector/OpenWeatherMapConnector.php:660 +#: lib/Connector/OpenWeatherMapConnector.php:675 msgid "Rain" msgstr "" -#: lib/Connector/OpenWeatherMapConnector.php:661 +#: lib/Connector/OpenWeatherMapConnector.php:676 msgid "Thunderstorm" msgstr "" -#: lib/Connector/OpenWeatherMapConnector.php:662 +#: lib/Connector/OpenWeatherMapConnector.php:677 msgid "Snow" msgstr "" -#: lib/Connector/OpenWeatherMapConnector.php:663 +#: lib/Connector/OpenWeatherMapConnector.php:678 msgid "Mist" msgstr "" -#: lib/Connector/OpenWeatherMapConnector.php:665 +#: lib/Connector/OpenWeatherMapConnector.php:680 msgid "Temperature (Imperial)" msgstr "" -#: lib/Connector/OpenWeatherMapConnector.php:667 +#: lib/Connector/OpenWeatherMapConnector.php:682 msgid "Temperature (Metric)" msgstr "" -#: lib/Connector/OpenWeatherMapConnector.php:669 +#: lib/Connector/OpenWeatherMapConnector.php:684 msgid "Apparent Temperature (Imperial)" msgstr "" -#: lib/Connector/OpenWeatherMapConnector.php:671 +#: lib/Connector/OpenWeatherMapConnector.php:686 msgid "Apparent Temperature (Metric)" msgstr "" -#: lib/Connector/OpenWeatherMapConnector.php:678 +#: lib/Connector/OpenWeatherMapConnector.php:693 msgid "North-Northeast" msgstr "" -#: lib/Connector/OpenWeatherMapConnector.php:679 +#: lib/Connector/OpenWeatherMapConnector.php:694 msgid "Northeast" msgstr "" -#: lib/Connector/OpenWeatherMapConnector.php:680 +#: lib/Connector/OpenWeatherMapConnector.php:695 msgid "East-Northeast" msgstr "" -#: lib/Connector/OpenWeatherMapConnector.php:682 +#: lib/Connector/OpenWeatherMapConnector.php:697 msgid "East-Southeast" msgstr "" -#: lib/Connector/OpenWeatherMapConnector.php:683 +#: lib/Connector/OpenWeatherMapConnector.php:698 msgid "Southeast" msgstr "" -#: lib/Connector/OpenWeatherMapConnector.php:684 +#: lib/Connector/OpenWeatherMapConnector.php:699 msgid "South-Southeast" msgstr "" -#: lib/Connector/OpenWeatherMapConnector.php:686 +#: lib/Connector/OpenWeatherMapConnector.php:701 msgid "South-Southwest" msgstr "" -#: lib/Connector/OpenWeatherMapConnector.php:687 +#: lib/Connector/OpenWeatherMapConnector.php:702 msgid "Southwest" msgstr "" -#: lib/Connector/OpenWeatherMapConnector.php:688 +#: lib/Connector/OpenWeatherMapConnector.php:703 msgid "West-Southwest" msgstr "" -#: lib/Connector/OpenWeatherMapConnector.php:690 +#: lib/Connector/OpenWeatherMapConnector.php:705 msgid "West-Northwest" msgstr "" -#: lib/Connector/OpenWeatherMapConnector.php:691 +#: lib/Connector/OpenWeatherMapConnector.php:706 msgid "Northwest" msgstr "" -#: lib/Connector/OpenWeatherMapConnector.php:692 +#: lib/Connector/OpenWeatherMapConnector.php:707 msgid "North-Northwest" msgstr "" -#: lib/Connector/OpenWeatherMapConnector.php:694 +#: lib/Connector/OpenWeatherMapConnector.php:709 msgid "Wind Bearing" msgstr "" -#: lib/Connector/OpenWeatherMapConnector.php:696 +#: lib/Connector/OpenWeatherMapConnector.php:711 msgid "Humidity (Percent)" msgstr "" @@ -26680,22 +26690,22 @@ msgstr "" msgid "Fetching Remote-DataSets" msgstr "" -#: lib/XTR/RemoteDataSetFetchTask.php:176 +#: lib/XTR/RemoteDataSetFetchTask.php:190 #, php-format msgid "No results for %s, truncate with no new data enabled" msgstr "" -#: lib/XTR/RemoteDataSetFetchTask.php:178 +#: lib/XTR/RemoteDataSetFetchTask.php:192 #, php-format msgid "No results for %s" msgstr "" -#: lib/XTR/RemoteDataSetFetchTask.php:186 +#: lib/XTR/RemoteDataSetFetchTask.php:200 #, php-format msgid "Error syncing DataSet %s" msgstr "" -#: lib/XTR/RemoteDataSetFetchTask.php:192 +#: lib/XTR/RemoteDataSetFetchTask.php:206 #, php-format msgid "Remote DataSet %s failed to synchronise" msgstr "" @@ -26775,20 +26785,20 @@ msgstr "" msgid "%s time disconnected summary report" msgstr "" -#: lib/Report/TimeDisconnectedSummary.php:265 +#: lib/Report/TimeDisconnectedSummary.php:266 #: lib/Report/DistributionReport.php:303 msgid "No display groups with View permissions" msgstr "" -#: lib/Report/TimeDisconnectedSummary.php:307 +#: lib/Report/TimeDisconnectedSummary.php:308 msgid "Days" msgstr "" -#: lib/Report/TimeDisconnectedSummary.php:454 +#: lib/Report/TimeDisconnectedSummary.php:494 msgid "Downtime" msgstr "" -#: lib/Report/TimeDisconnectedSummary.php:459 +#: lib/Report/TimeDisconnectedSummary.php:499 msgid "Uptime" msgstr "" @@ -26797,7 +26807,7 @@ msgstr "" msgid "%s bandwidth report" msgstr "" -#: lib/Report/ApiRequests.php:133 +#: lib/Report/ApiRequests.php:138 #, php-format msgid "%s API requests %s log report for User" msgstr "" From 7a76fa974a9926f46a09f8f394e8284688e25bdb Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Mon, 12 Aug 2024 13:33:36 +0100 Subject: [PATCH 176/203] Developer: fix import and hide temporary text area (#2699) xibosignage/xibo#3472 --- lib/Controller/Developer.php | 10 +++------- views/developer-template-edit-page.twig | 3 +-- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/lib/Controller/Developer.php b/lib/Controller/Developer.php index 3601e04717..a40b5a4aa0 100644 --- a/lib/Controller/Developer.php +++ b/lib/Controller/Developer.php @@ -27,7 +27,6 @@ use Xibo\Factory\ModuleFactory; use Xibo\Factory\ModuleTemplateFactory; use Xibo\Helper\SendFile; -use Xibo\Helper\UploadHandler; use Xibo\Service\MediaService; use Xibo\Service\UploadService; use Xibo\Support\Exception\AccessDeniedException; @@ -530,8 +529,6 @@ public function templateImport(Request $request, Response $response): Response|R $options = [ 'upload_dir' => $libraryFolder . 'temp/', - 'script_url' => $this->urlFor($request, 'developer.templates.import'), - 'upload_url' => $this->urlFor($request, 'developer.templates.import'), 'accept_file_types' => '/\.xml/i', 'libraryQuotaFull' => false, ]; @@ -539,10 +536,10 @@ public function templateImport(Request $request, Response $response): Response|R $this->getLog()->debug('Hand off to Upload Handler with options: ' . json_encode($options)); // Hand off to the Upload Handler provided by jquery-file-upload - $uploadService = new UploadService($options, $this->getLog(), $this->getState()); + $uploadService = new UploadService($libraryFolder . 'temp/', $options, $this->getLog(), $this->getState()); $uploadHandler = $uploadService->createUploadHandler(); - $uploadHandler->setPostProcessor(function ($file, $uploadHandler) { + $uploadHandler->setPostProcessor(function ($file, $uploadHandler) use ($libraryFolder) { // Return right away if the file already has an error. if (!empty($file->error)) { return $file; @@ -550,8 +547,7 @@ public function templateImport(Request $request, Response $response): Response|R $this->getUser()->isQuotaFullByUser(true); - /** @var UploadHandler $uploadHandler */ - $filePath = $uploadHandler->getUploadPath() . $file->fileName; + $filePath = $libraryFolder . 'temp/' . $file->fileName; // load the xml from uploaded file $xml = new \DOMDocument(); diff --git a/views/developer-template-edit-page.twig b/views/developer-template-edit-page.twig index df4b378106..a6f40383bf 100644 --- a/views/developer-template-edit-page.twig +++ b/views/developer-template-edit-page.twig @@ -152,8 +152,7 @@
- - +
From f94a5bcab21a9fbdbdd04132a4ba826da8bf4289 Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Mon, 12 Aug 2024 16:06:19 +0100 Subject: [PATCH 177/203] Developer: persist data type (#2700) xibosignage/xibo#3476 --- lib/Controller/Developer.php | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/Controller/Developer.php b/lib/Controller/Developer.php index a40b5a4aa0..ba43d13bef 100644 --- a/lib/Controller/Developer.php +++ b/lib/Controller/Developer.php @@ -334,6 +334,7 @@ public function templateEdit(Request $request, Response $response, $id): Respons ); }]); + $template->dataType = $dataType; $template->isEnabled = $params->getCheckbox('enabled'); // TODO: validate? From 009527855d8bfd0ffb95f5c88ed72b7b5bdebfa1 Mon Sep 17 00:00:00 2001 From: Mae Grace Baybay <87640602+mgbaybay@users.noreply.github.com> Date: Tue, 13 Aug 2024 00:19:17 +0800 Subject: [PATCH 178/203] Datasets: Fixed HTML sanitation in datasets table (#2698) --- views/dataset-dataentry-page.twig | 34 ++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/views/dataset-dataentry-page.twig b/views/dataset-dataentry-page.twig index cac32f5daa..4436a310ba 100644 --- a/views/dataset-dataentry-page.twig +++ b/views/dataset-dataentry-page.twig @@ -86,7 +86,33 @@ var multiSelectTitleTrans = "{% trans "Multi Select Mode" %}"; var editModeHelpTrans = "{% trans "Click on any row to edit" %}"; var multiSelectHelpTrans = "{% trans "Select one or more rows to delete" %}"; + const entityMap = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '/': '/', + '`': '`', + '=': '=' + }; + + function sanitizeHtml(string) { + return String(string).replace(/[&<>"'`=\/]/g, function (s) { + return entityMap[s]; + }); + } + + function validateHTMLData(str) { + let doc = new DOMParser().parseFromString(str, "text/html"); + + // If valid html, sanitize and format as a code + if (Array.from(doc.body.childNodes).some(node => node.nodeType === 1)) { + return `${sanitizeHtml(str)}`; + } + return str; + } cols.push({ "name": "id", "data": "id" }); {% for col in dataSet.getColumn() %} @@ -104,7 +130,13 @@ } }); {% else %} - cols.push({ "data": "{{ col.heading }}", "orderable": {% if col.showSort == 1 %}true{% else %}false{% endif %} }); + cols.push({ + "data": "{{ col.heading }}", + "orderable": {% if col.showSort == 1 %}true{% else %}false{% endif %}, + "render": function(data) { + return validateHTMLData(data); + } + }); {% endif %} {% endfor %} From 8fe9ff432a89ff36508854c6d573476f32a6c1e7 Mon Sep 17 00:00:00 2001 From: Ruben Pingol <128448242+rubenpingol-xibo@users.noreply.github.com> Date: Tue, 13 Aug 2024 01:18:27 +0800 Subject: [PATCH 179/203] XLR: Fix layout preview background (#2703) * XLR: Fix layout preview background relates to xibosignageltd/xibo-private#824 * XLR: Fix layout preview background relates to xibosignageltd/xibo-private#824 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9bedd2c169..c27c4c60fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12006,8 +12006,8 @@ "from": "git+https://github.com/xibosignage/xibo-interactive-control.git#44ad744a5a2a0af9e7fcdb828dbdbeff45cc40f0" }, "xibo-layout-renderer": { - "version": "git+https://github.com/xibosignage/xibo-layout-renderer.git#d867c24f59ad7462cf716caea6ee67bafe32474f", - "from": "git+https://github.com/xibosignage/xibo-layout-renderer.git#d867c24f59ad7462cf716caea6ee67bafe32474f" + "version": "git+https://github.com/xibosignage/xibo-layout-renderer.git#d20b8d606812650a915ddeed2e25f973c10f50d8", + "from": "git+https://github.com/xibosignage/xibo-layout-renderer.git#d20b8d606812650a915ddeed2e25f973c10f50d8" }, "xtend": { "version": "4.0.2", diff --git a/package.json b/package.json index 5a7dee418d..2b52e3a4a9 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,6 @@ "toastr": "~2.1.4", "underscore": "^1.12.1", "xibo-interactive-control": "git+https://github.com/xibosignage/xibo-interactive-control.git#44ad744a5a2a0af9e7fcdb828dbdbeff45cc40f0", - "xibo-layout-renderer": "git+https://github.com/xibosignage/xibo-layout-renderer.git#d867c24f59ad7462cf716caea6ee67bafe32474f" + "xibo-layout-renderer": "git+https://github.com/xibosignage/xibo-layout-renderer.git#d20b8d606812650a915ddeed2e25f973c10f50d8" } } From 6286621746a0d93abb2919d4729d816531ec4e68 Mon Sep 17 00:00:00 2001 From: Ruben Pingol <128448242+rubenpingol-xibo@users.noreply.github.com> Date: Tue, 13 Aug 2024 01:18:49 +0800 Subject: [PATCH 180/203] Layout Preview: Fix text element fit to selection (#2704) relates to xibosignage/xibo#3475 --- modules/templates/global-elements.xml | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/modules/templates/global-elements.xml b/modules/templates/global-elements.xml index 2623b224c5..06ca3ea4aa 100644 --- a/modules/templates/global-elements.xml +++ b/modules/templates/global-elements.xml @@ -251,8 +251,11 @@ if(properties.fitToArea) { // Set target for the text properties.fitTarget = 'div'; + var $selector = $(target).is('.global-elements-text') ? + $(target) : $(target).find('.global-elements-text'); + // Scale text to container - $(target).find('.global-elements-text').xiboTextScaler(properties); + $selector.xiboTextScaler(properties); } ]]> @@ -497,8 +500,11 @@ $(target).find('.date').each(function(_idx, dateEl){ // Set target for the text properties.fitTarget = '.date'; + var $selector = $(target).is('.global-elements-date') ? + $(target) : $(target).find('.global-elements-date'); + // Scale text to container - $(target).find('.global-elements-date').xiboTextScaler(properties); + $selector.xiboTextScaler(properties); } }); ]]> @@ -767,6 +773,9 @@ if (useCurrentDate) { // Set target for the text properties.fitTarget = '.date-advanced'; + var $selector = $(target).is('.global-elements-date-advanced') ? + $(target) : $(target).find('.global-elements-date-advanced'); + // Scale text to container $(target).find('.global-elements-date-advanced').xiboTextScaler(properties); @@ -799,8 +808,11 @@ if (useCurrentDate) { // Set target for the text properties.fitTarget = '.date-advanced'; + var $selector = $(target).is('.global-elements-date-advanced') ? + $(target) : $(target).find('.global-elements-date-advanced'); + // Scale text to container - $(target).find('.global-elements-date-advanced').xiboTextScaler(properties); + $selector.xiboTextScaler(properties); } } ]]> From 333388912adcfd209431e2e96b7084cbf1c29c83 Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Mon, 12 Aug 2024 19:15:49 +0100 Subject: [PATCH 181/203] Logging: fix log process to ensure we record page/method (#2702) Ensure we record page/method as early as possible, adding in user/session later. fixes xibosignageltd/xibo-private#825 --- lib/Helper/AccessibleMonologWriter.php | 39 -------------- lib/Helper/RouteLogProcessor.php | 54 +++++++++++++++++++ ...{LogProcessor.php => UserLogProcessor.php} | 23 +++----- lib/Middleware/ApiAuthorization.php | 9 ++++ lib/Middleware/AuthenticationTrait.php | 11 +++- lib/Middleware/Log.php | 36 +++++-------- lib/Service/LogService.php | 6 +-- lib/Service/LogServiceInterface.php | 6 ++- web/api/index.php | 2 +- web/index.php | 2 +- 10 files changed, 104 insertions(+), 84 deletions(-) delete mode 100644 lib/Helper/AccessibleMonologWriter.php create mode 100644 lib/Helper/RouteLogProcessor.php rename lib/Helper/{LogProcessor.php => UserLogProcessor.php} (75%) diff --git a/lib/Helper/AccessibleMonologWriter.php b/lib/Helper/AccessibleMonologWriter.php deleted file mode 100644 index aa9786e8f2..0000000000 --- a/lib/Helper/AccessibleMonologWriter.php +++ /dev/null @@ -1,39 +0,0 @@ -resource; - } - - public function addHandler($handler) { - if (!$this->resource) - $this->settings['handlers'][] = $handler; - else - $this->getWriter()->pushHandler($handler); - } - - public function addProcessor($processor) { - if (!$this->resource) - $this->settings['processors'][] = $processor; - else - $this->getWriter()->pushProcessor($processor); - } -} \ No newline at end of file diff --git a/lib/Helper/RouteLogProcessor.php b/lib/Helper/RouteLogProcessor.php new file mode 100644 index 0000000000..cf2aeeea2a --- /dev/null +++ b/lib/Helper/RouteLogProcessor.php @@ -0,0 +1,54 @@ +. + */ + + +namespace Xibo\Helper; + +/** + * Class RouteLogProcessor + * a process to add route/method information to the log record + * @package Xibo\Helper + */ +class RouteLogProcessor +{ + /** + * Log Processor + * @param string $route + * @param string $method + */ + public function __construct( + private readonly string $route, + private readonly string $method + ) { + } + + /** + * @param array $record + * @return array + */ + public function __invoke(array $record): array + { + $record['extra']['method'] = $this->method; + $record['extra']['route'] = $this->route; + return $record; + } +} diff --git a/lib/Helper/LogProcessor.php b/lib/Helper/UserLogProcessor.php similarity index 75% rename from lib/Helper/LogProcessor.php rename to lib/Helper/UserLogProcessor.php index 97b3b3cf99..dee8b66b52 100644 --- a/lib/Helper/LogProcessor.php +++ b/lib/Helper/UserLogProcessor.php @@ -24,23 +24,19 @@ namespace Xibo\Helper; /** - * Class LogProcessor + * Class UserLogProcessor * @package Xibo\Helper */ -class LogProcessor +class UserLogProcessor { /** - * Log Processor - * @param string $route - * @param string $method - * @param int|null $userId + * UserLogProcessor + * @param int $userId * @param int|null $sessionHistoryId * @param int|null $requestId */ public function __construct( - private readonly string $route, - private readonly string $method, - private readonly ?int $userId, + private readonly int $userId, private readonly ?int $sessionHistoryId, private readonly ?int $requestId ) { @@ -52,12 +48,7 @@ public function __construct( */ public function __invoke(array $record): array { - $record['extra']['method'] = $this->method; - $record['extra']['route'] = $this->route; - - if ($this->userId != null) { - $record['extra']['userId'] = $this->userId; - } + $record['extra']['userId'] = $this->userId; if ($this->sessionHistoryId != null) { $record['extra']['sessionHistoryId'] = $this->sessionHistoryId; @@ -69,4 +60,4 @@ public function __invoke(array $record): array return $record; } -} \ No newline at end of file +} diff --git a/lib/Middleware/ApiAuthorization.php b/lib/Middleware/ApiAuthorization.php index 075c6ba839..0e9fd98607 100644 --- a/lib/Middleware/ApiAuthorization.php +++ b/lib/Middleware/ApiAuthorization.php @@ -32,6 +32,7 @@ use Slim\App as App; use Slim\Routing\RouteContext; use Xibo\Factory\ApplicationScopeFactory; +use Xibo\Helper\UserLogProcessor; use Xibo\OAuth\AccessTokenRepository; use Xibo\Support\Exception\AccessDeniedException; @@ -204,6 +205,14 @@ public function process(Request $request, RequestHandler $handler): Response $logger->setUserId($user->userId); $this->app->getContainer()->set('user', $user); $logger->setRequestId($requestId); + + // Add this request information to the logger. + $logger->getLoggerInterface()->pushProcessor(new UserLogProcessor( + $user->userId, + null, + $requestId, + )); + return $handler->handle($validatedRequest->withAttribute('name', 'API')); } } diff --git a/lib/Middleware/AuthenticationTrait.php b/lib/Middleware/AuthenticationTrait.php index dd3ac24c4f..72d2d1972c 100644 --- a/lib/Middleware/AuthenticationTrait.php +++ b/lib/Middleware/AuthenticationTrait.php @@ -29,6 +29,7 @@ use Slim\Routing\RouteContext; use Xibo\Entity\User; use Xibo\Helper\HttpsDetect; +use Xibo\Helper\UserLogProcessor; /** * Trait AuthenticationTrait @@ -143,7 +144,15 @@ protected function getUser($userId, $ip, $sessionHistoryId): User */ protected function setUserForRequest($user) { - $this->app->getContainer()->set('user', $user); + $container = $this->app->getContainer(); + $container->set('user', $user); + + // Add this users information to the logger + $this->getLog()->getLoggerInterface()->pushProcessor(new UserLogProcessor( + $user->userId, + $this->getLog()->getSessionHistoryId(), + null, + )); } /** diff --git a/lib/Middleware/Log.php b/lib/Middleware/Log.php index 5760260bc8..8c2462b586 100644 --- a/lib/Middleware/Log.php +++ b/lib/Middleware/Log.php @@ -28,13 +28,18 @@ use Psr\Http\Server\RequestHandlerInterface as RequestHandler; use Psr\Log\LoggerInterface; use Slim\App as App; -use Xibo\Helper\LogProcessor; +use Xibo\Helper\RouteLogProcessor; +/** + * Log Middleware + */ class Log implements Middleware { - /* @var App $app */ - private $app; + private App $app; + /** + * @param $app + */ public function __construct($app) { $this->app = $app; @@ -44,18 +49,14 @@ public function __construct($app) * @param Request $request * @param RequestHandler $handler * @return Response + * @throws \Psr\Container\ContainerExceptionInterface + * @throws \Psr\Container\NotFoundExceptionInterface */ public function process(Request $request, RequestHandler $handler): Response { $container = $this->app->getContainer(); - self::addLogProcessorToLogger( - $container->get('logger'), - $request, - $container->get('logService')->getUserId(), - $container->get('logService')->getSessionHistoryId(), - $container->get('logService')->getRequestId(), - ); + self::addLogProcessorToLogger($container->get('logger'), $request); return $handler->handle($request); } @@ -63,23 +64,14 @@ public function process(Request $request, RequestHandler $handler): Response /** * @param LoggerInterface $logger * @param \Psr\Http\Message\ServerRequestInterface $request - * @param ?int $userId - * @param ?int $sessionHistoryId */ public static function addLogProcessorToLogger( LoggerInterface $logger, Request $request, - ?int $userId = null, - ?int $sessionHistoryId = null, - ?int $requestId = null - ) { - $logHelper = new LogProcessor( + ): void { + $logger->pushProcessor(new RouteLogProcessor( $request->getUri()->getPath(), $request->getMethod(), - $userId, - $sessionHistoryId, - $requestId - ); - $logger->pushProcessor($logHelper); + )); } } diff --git a/lib/Service/LogService.php b/lib/Service/LogService.php index 4715edebaa..f11386900c 100644 --- a/lib/Service/LogService.php +++ b/lib/Service/LogService.php @@ -117,17 +117,17 @@ public function setRequestId($requestId) $this->requestId = $requestId; } - public function getUserId(): int + public function getUserId(): ?int { return $this->userId; } - public function getSessionHistoryId() + public function getSessionHistoryId(): ?int { return $this->sessionHistoryId; } - public function getRequestId() + public function getRequestId(): ?int { return $this->requestId; } diff --git a/lib/Service/LogServiceInterface.php b/lib/Service/LogServiceInterface.php index fc4143766b..6ecd6badd7 100644 --- a/lib/Service/LogServiceInterface.php +++ b/lib/Service/LogServiceInterface.php @@ -45,6 +45,10 @@ public function __construct($logger, $mode = 'production'); */ public function getLoggerInterface(): LoggerInterface; + public function getUserId(): ?int; + public function getSessionHistoryId(): ?int; + public function getRequestId(): ?int; + /** * Set the user Id * @param int $userId @@ -155,4 +159,4 @@ public static function resolveLogLevel($level); * @param $level */ public function setLevel($level); -} \ No newline at end of file +} diff --git a/web/api/index.php b/web/api/index.php index 8aaa330bee..1b2b009eb9 100644 --- a/web/api/index.php +++ b/web/api/index.php @@ -69,9 +69,9 @@ $app->add(new \Xibo\Middleware\ConnectorMiddleware($app)); $app->add(new \Xibo\Middleware\ListenersMiddleware($app)); -$app->add(new \Xibo\Middleware\Log($app)); $app->add(new \Xibo\Middleware\ApiAuthorization($app)); $app->add(new \Xibo\Middleware\State($app)); +$app->add(new \Xibo\Middleware\Log($app)); $app->add(new \Xibo\Middleware\Storage($app)); $app->add(new \Xibo\Middleware\Xmr($app)); $app->addRoutingMiddleware(); diff --git a/web/index.php b/web/index.php index 26e10012ad..2c7ca8e570 100644 --- a/web/index.php +++ b/web/index.php @@ -95,7 +95,6 @@ $app->add(new \Xibo\Middleware\Theme($app)); $app->add(new \Xibo\Middleware\CsrfGuard($app)); $app->add(new \Xibo\Middleware\Csp($container)); -$app->add(new \Xibo\Middleware\Log($app)); // Authentication $authentication = ($container->get('configService')->authentication != null) @@ -109,6 +108,7 @@ // TODO reconfigure this and enable //$app->add(new Xibo\Middleware\HttpCache()); $app->add(new \Xibo\Middleware\State($app)); +$app->add(new \Xibo\Middleware\Log($app)); $app->add(TwigMiddleware::createFromContainer($app)); $app->add(new \Xibo\Middleware\Storage($app)); $app->add(new \Xibo\Middleware\Xmr($app)); From 7e213fcef9bb8ce0074942ca6d4a12bc20423b78 Mon Sep 17 00:00:00 2001 From: Nadz Mangandog <61860661+nadzpogi@users.noreply.github.com> Date: Tue, 13 Aug 2024 14:05:53 +0800 Subject: [PATCH 182/203] Schedule Criteria: CMS API + XMR update to displays (#2686) relates to xibosignageltd/xibo-private#807 --- lib/Controller/DisplayGroup.php | 109 +++++++++++++++++++++++ lib/XMR/ScheduleCriteriaUpdateAction.php | 67 ++++++++++++++ lib/routes.php | 1 + 3 files changed, 177 insertions(+) create mode 100644 lib/XMR/ScheduleCriteriaUpdateAction.php diff --git a/lib/Controller/DisplayGroup.php b/lib/Controller/DisplayGroup.php index ab201537db..33eb4735e1 100644 --- a/lib/Controller/DisplayGroup.php +++ b/lib/Controller/DisplayGroup.php @@ -21,6 +21,7 @@ */ namespace Xibo\Controller; +use Psr\Http\Message\ResponseInterface; use Slim\Http\Response as Response; use Slim\Http\ServerRequest as Request; use Xibo\Entity\Display; @@ -36,6 +37,7 @@ use Xibo\Factory\TagFactory; use Xibo\Service\PlayerActionServiceInterface; use Xibo\Support\Exception\AccessDeniedException; +use Xibo\Support\Exception\ControllerNotImplemented; use Xibo\Support\Exception\GeneralException; use Xibo\Support\Exception\InvalidArgumentException; use Xibo\Support\Exception\NotFoundException; @@ -43,7 +45,9 @@ use Xibo\XMR\CollectNowAction; use Xibo\XMR\CommandAction; use Xibo\XMR\OverlayLayoutAction; +use Xibo\XMR\PlayerActionException; use Xibo\XMR\RevertToSchedule; +use Xibo\XMR\ScheduleCriteriaUpdateAction; use Xibo\XMR\TriggerWebhookAction; /** @@ -2871,4 +2875,109 @@ public function triggerWebhook(Request $request, Response $response, $id) return $this->render($request, $response); } + + /** + * @SWG\Post( + * path="/displaygroup/criteria[/{displayGroupId}]", + * operationId="ScheduleCriteriaUpdate", + * tags={"displayGroup"}, + * summary="Action: Push Criteria Update", + * description="Send criteria updates to the specified DisplayGroup or to all displays if displayGroupId is not + * provided.", + * @SWG\Parameter( + * name="displayGroupId", + * in="path", + * description="The display group id", + * type="integer", + * required=true + * ), + * @SWG\Parameter( + * name="criteriaUpdates", + * in="body", + * description="The criteria updates to send to the Player", + * required=true, + * @SWG\Schema( + * type="array", + * @SWG\Items( + * type="object", + * @SWG\Property(property="metric", type="string"), + * @SWG\Property(property="value", type="string"), + * @SWG\Property(property="ttl", type="integer") + * ) + * ) + * ), + * @SWG\Response( + * response=204, + * description="Successful operation" + * ), + * @SWG\Response( + * response=400, + * description="Invalid criteria format" + * ) + * ) + * + * @param Request $request + * @param Response $response + * @param int $displayGroupId + * @return ResponseInterface|Response + * @throws ControllerNotImplemented + * @throws GeneralException + * @throws NotFoundException + * @throws PlayerActionException + */ + public function pushCriteriaUpdate(Request $request, Response $response, int $displayGroupId): Response|ResponseInterface + { + $sanitizedParams = $this->getSanitizer($request->getParams()); + + // Get criteria updates + $criteriaUpdates = $sanitizedParams->getArray('criteriaUpdates'); + + // ensure criteria updates exists + if (empty($criteriaUpdates)) { + throw new InvalidArgumentException(__('No criteria found.'), 'criteriaUpdates'); + } + + // Initialize array to hold sanitized criteria updates + $sanitizedCriteriaUpdates = []; + + // Loop through each criterion and sanitize the input + foreach ($criteriaUpdates as $criteria) { + $criteriaSanitizer = $this->getSanitizer($criteria); + + // Sanitize and retrieve the metric, value, and ttl + $metric = $criteriaSanitizer->getString('metric'); + $value = $criteriaSanitizer->getString('value'); + $ttl = $criteriaSanitizer->getInt('ttl'); + + // Ensure each criterion has metric, value, and ttl + if (empty($metric) || empty($value) || !isset($ttl)) { + // Throw an exception if any of the required fields are missing or empty + throw new PlayerActionException( + __('Invalid criteria format. Metric, value, and ttl must all be present and not empty.') + ); + } + + // Add sanitized criteria + $sanitizedCriteriaUpdates[] = [ + 'metric' => $metric, + 'value' => $value, + 'ttl' => abs($ttl) + ]; + } + + // Create and send the player action to displays under the display group + $this->playerAction->sendAction( + $this->displayFactory->getByDisplayGroupId($displayGroupId), + (new ScheduleCriteriaUpdateAction())->setCriteriaUpdates($sanitizedCriteriaUpdates) + ); + + // Return + $this->getState()->hydrate([ + 'httpStatus' => 204, + 'message' => __('Schedule criteria updates sent to players.'), + 'id' => $displayGroupId + ]); + + return $this->render($request, $response); + } } diff --git a/lib/XMR/ScheduleCriteriaUpdateAction.php b/lib/XMR/ScheduleCriteriaUpdateAction.php new file mode 100644 index 0000000000..5b703fff7e --- /dev/null +++ b/lib/XMR/ScheduleCriteriaUpdateAction.php @@ -0,0 +1,67 @@ +. + */ + +namespace Xibo\XMR; + +/** + * Class ScheduleCriteriaUpdateAction + * @package Xibo\XMR + */ +class ScheduleCriteriaUpdateAction extends PlayerAction +{ + /** + * @var array + */ + public $criteriaUpdates = []; + + public function __construct() + { + $this->setQos(10); + } + + /** + * Set criteria updates + * @param array $criteriaUpdates an array of criteria updates + * @return $this + */ + public function setCriteriaUpdates(array $criteriaUpdates) + { + $this->criteriaUpdates = $criteriaUpdates; + return $this; + } + + /** + * @inheritdoc + */ + public function getMessage(): string + { + $this->action = 'criteriaUpdate'; + + // Ensure criteriaUpdates array is not empty + if (empty($this->criteriaUpdates)) { + // Throw an exception if criteriaUpdates is not provided + throw new PlayerActionException(__('Criteria updates not provided.')); + } + + return $this->serializeToJson(['criteriaUpdates']); + } +} diff --git a/lib/routes.php b/lib/routes.php index bc419848a8..d8318337b6 100644 --- a/lib/routes.php +++ b/lib/routes.php @@ -423,6 +423,7 @@ $app->post('/displaygroup', ['\Xibo\Controller\DisplayGroup','add']) ->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['displaygroup.add'])) ->setName('displayGroup.add'); +$app->post('/displaygroup/criteria/{displayGroupId}', ['\Xibo\Controller\DisplayGroup','pushCriteriaUpdate'])->setName('displayGroup.criteria.push'); $app->post('/displaygroup/{id}/action/collectNow', ['\Xibo\Controller\DisplayGroup','collectNow']) ->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['displaygroup.view'])) From 39dff1b5cf75dbe06d75246a8b53837f0516e1bd Mon Sep 17 00:00:00 2001 From: Nadz Mangandog <61860661+nadzpogi@users.noreply.github.com> Date: Tue, 13 Aug 2024 14:09:09 +0800 Subject: [PATCH 183/203] Bugfix: DataSet's value discrepancy when storing large integers in number values (#2696) relates to xibosignage/xibo#3404 --- lib/Entity/DataSetColumn.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Entity/DataSetColumn.php b/lib/Entity/DataSetColumn.php index 837f00cb19..b153f3b43a 100644 --- a/lib/Entity/DataSetColumn.php +++ b/lib/Entity/DataSetColumn.php @@ -466,7 +466,7 @@ private function sqlDataType() switch ($this->dataTypeId) { case 2: - $dataType = 'FLOAT'; + $dataType = 'DOUBLE'; break; case 3: From 8f2785e23f615a4ae5a821b7afac2999e1ec4dce Mon Sep 17 00:00:00 2001 From: Nadz Mangandog <61860661+nadzpogi@users.noreply.github.com> Date: Tue, 13 Aug 2024 14:13:47 +0800 Subject: [PATCH 184/203] Bugfix: Dataset Advanced Filter - display tag substitution is not replaced at runtime on the player (#2697) relates to xibosignage/xibo#3463 --- lib/Entity/DataSet.php | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/lib/Entity/DataSet.php b/lib/Entity/DataSet.php index 7d435cb656..674f90e7e8 100644 --- a/lib/Entity/DataSet.php +++ b/lib/Entity/DataSet.php @@ -119,7 +119,8 @@ class DataSet implements \JsonSerializable public $isRealTime = 0; /** - * @SWG\Property(description="Indicates the source of the data connector. Requires the Real time flag. Can be null, user-defined, or a connector.") + * @SWG\Property(description="Indicates the source of the data connector. Requires the Real time flag. Can be null, + * user-defined, or a connector.") * @var string */ public $dataConnectorSource; @@ -499,9 +500,11 @@ public function getData($filterBy = [], $options = [], $extraParams = []) 'connection' => 'default' ], $options); + // Params (start from extraParams supplied) + $params = $extraParams; + // Fetch display tag value/s if ($filter != '' && $displayId != 0) { - // Define the regular expression to match [Tag:...] $pattern = '/\[Tag:[^]]+\]/'; @@ -529,6 +532,8 @@ public function getData($filterBy = [], $options = [], $extraParams = []) ]; } + $tagCount = 1; + // Loop through each tag and get the actual tag value from the database foreach ($displayTags as $tag) { $tagSanitizer = $this->getSanitizer($tag); @@ -539,21 +544,23 @@ public function getData($filterBy = [], $options = [], $extraParams = []) $query = 'SELECT `lktagdisplaygroup`.`value` AS tagValue FROM `lkdisplaydg` - INNER JOIN `displaygroup` ON `displaygroup`.displayGroupId = `lkdisplaydg`.displayGroupId + INNER JOIN `displaygroup` + ON `displaygroup`.displayGroupId = `lkdisplaydg`.displayGroupId AND `displaygroup`.isDisplaySpecific = 1 - INNER JOIN `lktagdisplaygroup` ON `lktagdisplaygroup`.displayGroupId = `lkdisplaydg`.displayGroupId + INNER JOIN `lktagdisplaygroup` + ON `lktagdisplaygroup`.displayGroupId = `lkdisplaydg`.displayGroupId INNER JOIN `tag` ON `lktagdisplaygroup`.tagId = `tag`.tagId WHERE `lkdisplaydg`.displayId = :displayId AND `tag`.`tag` = :tagName LIMIT 1'; - $params = [ + $tagParams = [ 'displayId' => $displayId, 'tagName' => $tagName ]; // Execute the query - $results = $this->getStore()->select($query, $params); + $results = $this->getStore()->select($query, $tagParams); // Determine the tag value if (!empty($results)) { @@ -564,14 +571,14 @@ public function getData($filterBy = [], $options = [], $extraParams = []) } // Replace the tag string in the filter with the actual tag value or default value - $filter = str_replace($tagString, $tagValue, $filter); + $filter = str_replace($tagString, ':tagValue_'.$tagCount, $filter); + $params['tagValue_'.$tagCount] = $tagValue; + + $tagCount++; } } } - // Params (start from extraParams supplied) - $params = $extraParams; - // Sanitize the filter options provided // Get the Latitude and Longitude ( might be used in a formula ) if ($displayId == 0) { @@ -1094,8 +1101,8 @@ public function deleteData() */ private function add() { - $columns = 'DataSet, Description, UserID, `code`, `isLookup`, `isRemote`,'; - $columns .= '`lastDataEdit`, `lastClear`, `folderId`, `permissionsFolderId`, `isRealTime`, `dataConnectorSource`'; + $columns = 'DataSet, Description, UserID, `code`, `isLookup`, `isRemote`, `lastDataEdit`,'; + $columns .= '`lastClear`, `folderId`, `permissionsFolderId`, `isRealTime`, `dataConnectorSource`'; $values = ':dataSet, :description, :userId, :code, :isLookup, :isRemote,'; $values .= ':lastDataEdit, :lastClear, :folderId, :permissionsFolderId, :isRealTime, :dataConnectorSource'; From a5457afed993571abe50904983e70feac7db7914 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mauro=20Ferr=C3=A3o?= Date: Tue, 13 Aug 2024 13:32:12 +0100 Subject: [PATCH 185/203] Shrunken login page (#2691) fixes xibosignageltd/xibo-private#827 --- views/login.twig | 4 ++-- views/non-authed.twig | 4 ++-- views/tfa.twig | 4 ++-- views/upgrade-in-progress-page.twig | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/views/login.twig b/views/login.twig index e569272a86..53ceee5dc3 100644 --- a/views/login.twig +++ b/views/login.twig @@ -18,8 +18,8 @@ } body { - padding-top: 40px; - padding-bottom: 40px; + padding-top: 40px !important; + padding-bottom: 40px !important; background-color: #f5f5f5; font-size: 1rem; } diff --git a/views/non-authed.twig b/views/non-authed.twig index cc448a9b9f..3b5c0c6988 100644 --- a/views/non-authed.twig +++ b/views/non-authed.twig @@ -35,8 +35,8 @@