Files
Cloud-CMS/lib/Entity/Layout.php
Matt Batchelder 05ce0da296 Initial Upload
2025-12-02 10:32:59 -05:00

3133 lines
115 KiB
PHP

<?php
/*
* 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 <http://www.gnu.org/licenses/>.
*/
namespace Xibo\Entity;
use Carbon\Carbon;
use Respect\Validation\Validator as v;
use Xibo\Event\LayoutBuildEvent;
use Xibo\Event\LayoutBuildRegionEvent;
use Xibo\Event\SubPlaylistValidityEvent;
use Xibo\Factory\ActionFactory;
use Xibo\Factory\CampaignFactory;
use Xibo\Factory\DataSetFactory;
use Xibo\Factory\FolderFactory;
use Xibo\Factory\FontFactory;
use Xibo\Factory\LayoutFactory;
use Xibo\Factory\MediaFactory;
use Xibo\Factory\ModuleFactory;
use Xibo\Factory\ModuleTemplateFactory;
use Xibo\Factory\PermissionFactory;
use Xibo\Factory\PlaylistFactory;
use Xibo\Factory\RegionFactory;
use Xibo\Factory\TagFactory;
use Xibo\Factory\WidgetDataFactory;
use Xibo\Helper\DateFormatHelper;
use Xibo\Helper\Environment;
use Xibo\Helper\Profiler;
use Xibo\Helper\Status;
use Xibo\Service\ConfigServiceInterface;
use Xibo\Service\LogServiceInterface;
use Xibo\Storage\StorageServiceInterface;
use Xibo\Support\Exception\DuplicateEntityException;
use Xibo\Support\Exception\GeneralException;
use Xibo\Support\Exception\InvalidArgumentException;
use Xibo\Support\Exception\NotFoundException;
/**
* Class Layout
* @package Xibo\Entity
*
* @SWG\Definition()
*/
class Layout implements \JsonSerializable
{
use EntityTrait;
use TagLinkTrait;
/**
* @SWG\Property(
* description="The layoutId"
* )
* @var int
*/
public $layoutId;
/**
* @var int
* @SWG\Property(
* description="The userId of the Layout Owner"
* )
*/
public $ownerId;
/**
* @var int
* @SWG\Property(
* description="The id of the Layout's dedicated Campaign"
* )
*/
public $campaignId;
/**
* @var int
* @SWG\Property(
* description="The parentId, if this Layout has a draft"
* )
*/
public $parentId;
/**
* @var int
* @SWG\Property(
* description="The Status Id"
* )
*/
public $publishedStatusId = 1;
/**
* @var string
* @SWG\Property(
* description="The Published Status (Published, Draft or Pending Approval"
* )
*/
public $publishedStatus;
/**
* @var string
* @SWG\Property(
* description="The Published Date"
* )
*/
public $publishedDate;
/**
* @var int
* @SWG\Property(
* description="The id of the image media set as the background"
* )
*/
public $backgroundImageId;
/**
* @var int
* @SWG\Property(
* description="The XLF schema version"
* )
*/
public $schemaVersion;
/**
* @var string
* @SWG\Property(
* description="The name of the Layout"
* )
*/
public $layout;
/**
* @var string
* @SWG\Property(
* description="The description of the Layout"
* )
*/
public $description;
/**
* @var string
* @SWG\Property(
* description="A HEX string representing the Layout background color"
* )
*/
public $backgroundColor;
/**
* @var string
* @SWG\Property(
* description="The datetime the Layout was created"
* )
*/
public $createdDt;
/**
* @var string
* @SWG\Property(
* description="The datetime the Layout was last modified"
* )
*/
public $modifiedDt;
/**
* @var int
* @SWG\Property(
* description="Flag indicating the Layout status"
* )
*/
public $status;
/**
* @var int
* @SWG\Property(
* description="Flag indicating whether the Layout is retired"
* )
*/
public $retired;
/**
* @var int
* @SWG\Property(
* description="The Layer that the background should occupy"
* )
*/
public $backgroundzIndex;
/**
* @var double
* @SWG\Property(
* description="The Layout Width"
* )
*/
public $width;
/**
* @var double
* @SWG\Property(
* description="The Layout Height"
* )
*/
public $height;
/**
* @var string
* @SWG\Property(
* description="The Layout Orientation"
* )
*/
public $orientation;
/**
* @var int
* @SWG\Property(
* description="If this Layout has been requested by Campaign, then this is the display order of the Layout within the Campaign"
* )
*/
public $displayOrder;
/**
* @var int
* @SWG\Property(
* description="A read-only estimate of this Layout's total duration in seconds. This is equal to the longest region duration and is valid when the layout status is 1 or 2."
* )
*/
public $duration;
/**
* @var string
* @SWG\Property(description="A status message detailing any errors with the layout")
*/
public $statusMessage;
/**
* @var int
* @SWG\Property(
* description="Flag indicating whether the Layout stat is enabled"
* )
*/
public $enableStat;
/**
* @var int
* @SWG\Property(
* description="Flag indicating whether the default transitions should be applied to this Layout"
* )
*/
public $autoApplyTransitions;
/**
* @var string
* @SWG\Property(description="Code identifier for this Layout")
*/
public $code;
/**
* @SWG\Property(description="Is this layout locked by another user?")
* @var bool
*/
public $isLocked;
// Child items
/**
* @SWG\Property(description="An array of Regions belonging to this Layout")
* @var Region[]
*/
public $regions = [];
/**
* @SWG\Property(description="Tags associated with this Layout, array of TagLink objects")
* @var TagLink[]
*/
public $tags = [];
/** @var Region[] */
public $drawers = [];
/** @var Action[] */
public $actions = [];
/** @var \Xibo\Entity\Permission[] */
public $permissions = [];
/** @var \Xibo\Entity\Campaign[] */
public $campaigns = [];
// Read only properties
public $owner;
public $groupsWithPermissions;
/**
* @SWG\Property(description="The id of the Folder this Layout belongs to")
* @var int
*/
public $folderId;
/**
* @SWG\Property(description="The id of the Folder responsible for providing permissions for this Layout")
* @var int
*/
public $permissionsFolderId;
// Private
/** @var TagLink[] */
private $unlinkTags = [];
/** @var TagLink[] */
private $linkTags = [];
// Handle empty regions
private $hasEmptyRegion = false;
// Flag to indicate we've not built this layout this session.
private $hasBuilt = false;
public static $loadOptionsMinimum = [
'loadPlaylists' => false,
'loadTags' => false,
'loadPermissions' => false,
'loadCampaigns' => false
];
public static $saveOptionsMinimum = [
'saveLayout' => true,
'saveRegions' => false,
'saveTags' => false,
'setBuildRequired' => true,
'validate' => false,
'audit' => false,
'notify' => false
];
/**
* @var ConfigServiceInterface
*/
private $config;
/**
* @var PermissionFactory
*/
private $permissionFactory;
/**
* @var RegionFactory
*/
private $regionFactory;
/**
* @var TagFactory
*/
private $tagFactory;
/**
* @var CampaignFactory
*/
private $campaignFactory;
/**
* @var LayoutFactory
*/
private $layoutFactory;
/**
* @var MediaFactory
*/
private $mediaFactory;
/**
* @var ModuleFactory
*/
private $moduleFactory;
/**
* @var ModuleTemplateFactory
*/
private $moduleTemplateFactory;
/** @var PlaylistFactory */
private $playlistFactory;
/** @var ActionFactory */
private $actionFactory;
/** @var FolderFactory */
private $folderFactory;
/**
* @var FontFactory
*/
private $fontFactory;
/**
* Entity constructor.
* @param StorageServiceInterface $store
* @param LogServiceInterface $log
* @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
* @param ConfigServiceInterface $config
* @param PermissionFactory $permissionFactory
* @param RegionFactory $regionFactory
* @param TagFactory $tagFactory
* @param CampaignFactory $campaignFactory
* @param LayoutFactory $layoutFactory
* @param MediaFactory $mediaFactory
* @param ModuleFactory $moduleFactory
* @param ModuleTemplateFactory $moduleTemplateFactory
* @param PlaylistFactory $playlistFactory
* @param ActionFactory $actionFactory
* @param FolderFactory $folderFactory
*/
public function __construct(
$store,
$log,
$dispatcher,
$config,
$permissionFactory,
$regionFactory,
$tagFactory,
$campaignFactory,
$layoutFactory,
$mediaFactory,
$moduleFactory,
$moduleTemplateFactory,
$playlistFactory,
$actionFactory,
$folderFactory,
FontFactory $fontFactory
) {
$this->setCommonDependencies($store, $log, $dispatcher);
$this->setPermissionsClass('Xibo\Entity\Campaign');
$this->config = $config;
$this->permissionFactory = $permissionFactory;
$this->regionFactory = $regionFactory;
$this->tagFactory = $tagFactory;
$this->campaignFactory = $campaignFactory;
$this->layoutFactory = $layoutFactory;
$this->mediaFactory = $mediaFactory;
$this->moduleFactory = $moduleFactory;
$this->moduleTemplateFactory = $moduleTemplateFactory;
$this->playlistFactory = $playlistFactory;
$this->actionFactory = $actionFactory;
$this->folderFactory = $folderFactory;
$this->fontFactory = $fontFactory;
}
public function __clone()
{
// Clear the layout id
$this->layoutId = null;
$this->campaignId = null;
$this->code = null;
$this->hash = null;
$this->permissions = [];
$this->tags = [];
$this->linkTags = [];
// A normal clone (for copy) will set this to Published, so that the copy is published.
$this->publishedStatusId = 1;
// Clone the regions
$this->regions = array_map(function ($object) { return clone $object; }, $this->regions);
// Clone drawers
$this->drawers = array_map(function ($object) { return clone $object; }, $this->drawers);
// Clone actions
$this->actions = array_map(function ($object) { return clone $object; }, $this->actions);
}
/**
* @return string
*/
public function __toString()
{
$countRegions = is_array($this->regions) ? count($this->regions) : 0;
$countTags = is_array($this->tags) ? count($this->tags) : 0;
$countDrawers = is_array($this->drawers) ? count($this->drawers) : 0;
$statusMessages = $this->getStatusMessage();
$countMessages = is_array($statusMessages) ? count($statusMessages) : 0;
return sprintf('Layout %s - %d x %d. Regions = %d, Drawers = %d, Tags = %d. layoutId = %d. Status = %d, messages %d', $this->layout, $this->width, $this->height, $countRegions, $countDrawers, $countTags, $this->layoutId, $this->status, $countMessages);
}
/**
* @return string
*/
private function hash()
{
return md5($this->layoutId . $this->ownerId . $this->campaignId . $this->backgroundImageId . $this->backgroundColor . $this->width . $this->height . $this->status . $this->description . json_encode($this->statusMessage) . $this->publishedStatusId . json_encode($this->actions));
}
/**
* Get the Id
* @return int
*/
public function getId()
{
return $this->campaignId;
}
public function getPermissionFolderId()
{
return $this->permissionsFolderId;
}
/**
* Get the OwnerId
* @return int
*/
public function getOwnerId()
{
return $this->ownerId;
}
/**
* Sets the Owner of the Layout (including children)
* @param int $ownerId
* @param bool $cascade Cascade ownership change down to Playlist records
* @throws GeneralException
* @throws NotFoundException
*/
public function setOwner($ownerId, $cascade = false)
{
$this->getLog()->debug('setOwner: layoutId=' . $this->layoutId . ', ownerId=' . $ownerId);
$this->load();
$this->ownerId = $ownerId;
$allRegions = array_merge($this->regions, $this->drawers);
foreach ($allRegions as $region) {
/* @var Region $region */
$region->setOwner($ownerId, $cascade);
}
}
/**
* @return bool if this Layout has an empty Region.
*/
public function hasEmptyRegion()
{
return $this->hasEmptyRegion;
}
/**
* Helper function that checks if Layout has an empty Region
* without building it.
*/
public function checkForEmptyRegion()
{
$this->load();
foreach ($this->regions as $region) {
$widgets = $region->getPlaylist()->setModuleFactory($this->moduleFactory)->expandWidgets();
$countWidgets = count($widgets);
if ($countWidgets <= 0) {
$this->hasEmptyRegion = true;
break;
}
}
return $this->hasEmptyRegion;
}
/**
* Set the status of this layout to indicate a build is required
*/
private function setBuildRequired()
{
$this->status = 3;
}
/**
* Load Regions from a Layout
* @param int $regionId
* @return Region
* @throws NotFoundException
*/
public function getRegion($regionId)
{
foreach ($this->regions as $region) {
/* @var Region $region */
if ($region->regionId == $regionId) {
return $region;
}
}
throw new NotFoundException(__('Cannot find region'));
}
/**
* Load Drawers from a Layout
* @param int $regionId
* @return Region
* @throws NotFoundException
*/
public function getDrawer($regionId)
{
foreach ($this->drawers as $drawer) {
/* @var Region $drawer */
if ($drawer->regionId == $regionId) {
return $drawer;
}
}
throw new NotFoundException(__('Cannot find drawer region'));
}
/**
* Load both Regions and Drawers from a Layout
* @param int $regionId
* @return Region
* @throws NotFoundException
*/
public function getRegionOrDrawer($regionId)
{
/** @var Region[] $allRegions */
$allRegions = array_merge($this->regions, $this->drawers);
foreach ($allRegions as $region) {
/* @var Region $region */
if ($region->regionId == $regionId) {
return $region;
}
}
throw new NotFoundException(__('Cannot find Region or Drawer'));
}
/**
* Get All Widgets assigned to this Layout
* @return Widget[]
* @throws NotFoundException
*/
public function getAllWidgets()
{
$widgets = [];
$allRegions = array_merge($this->regions, $this->drawers);
/** @var Region $region */
foreach ($allRegions as $region) {
$widgets = array_merge($region->getPlaylist()->widgets, $widgets);
}
return $widgets;
}
/**
* Get Region Widgets assigned to this Layout
* @return Widget[]
* @throws NotFoundException
*/
public function getRegionWidgets()
{
$widgets = [];
foreach ($this->regions as $region) {
$widgets = array_merge($region->getPlaylist()->widgets, $widgets);
}
return $widgets;
}
/**
* Get Drawer Widgets assigned to this Layout
* @return Widget[]
* @throws NotFoundException
*/
public function getDrawerWidgets()
{
$widgets = [];
foreach ($this->drawers as $drawer) {
$widgets = array_merge($drawer->getPlaylist()->widgets, $widgets);
}
return $widgets;
}
/**
* Is this Layout Editable - i.e. are we in a draft state or not.
* @return bool true if this layout is editable
*/
public function isEditable()
{
return ($this->publishedStatusId === 2); // Draft
}
/**
* Is this Layout a Child?
* @return bool
*/
public function isChild()
{
return ($this->parentId !== null);
}
/**
* @return bool true if this layout has a draft
*/
public function hasDraft(): bool
{
return $this->isEditable() && !$this->isChild();
}
/**
* Is this Layout a Template?
* @return bool
*/
public function isTemplate(): bool
{
return $this->hasTag('template');
}
/**
* @return \Xibo\Entity\TagLink[]
*/
public function getTags(): array
{
return $this->tags;
}
/**
* @return array
*/
public function getStatusMessage()
{
if ($this->statusMessage === null || empty($this->statusMessage)) {
return [];
}
if (is_array($this->statusMessage)) {
return $this->statusMessage;
}
$this->statusMessage = json_decode($this->statusMessage, true);
return $this->statusMessage;
}
/**
* Push a new message
* @param $message
*/
public function pushStatusMessage($message)
{
$this->getStatusMessage();
$this->statusMessage[] = $message;
}
/**
* Clear status message
*/
private function clearStatusMessage()
{
$this->statusMessage = null;
}
/**
* Load this Layout
* @param array $options
* @throws NotFoundException
*/
public function load($options = [])
{
$options = array_merge([
'loadPlaylists' => true,
'loadPermissions' => true,
'loadCampaigns' => true,
'loadActions' => true,
], $options);
if ($this->loaded || $this->layoutId == 0) {
return;
}
$this->getLog()->debug(sprintf('Loading Layout %d with options %s', $this->layoutId, json_encode($options)));
// Load permissions
if ($options['loadPermissions']) {
$this->permissions = $this->permissionFactory->getByObjectId('Xibo\\Entity\\Campaign', $this->campaignId);
}
// Load all regions
$this->regions = $this->regionFactory->getByLayoutId($this->layoutId);
// load all drawers
$this->drawers = $this->regionFactory->getDrawersByLayoutId($this->layoutId);
if ($options['loadPlaylists']) {
$this->loadPlaylists($options);
}
// Load Campaigns
if ($options['loadCampaigns']) {
$this->campaigns = $this->campaignFactory->getByLayoutId($this->layoutId);
}
// Load Actions
if ($options['loadActions']) {
$this->actions = $this->actionFactory->getBySourceAndSourceId('layout', $this->layoutId);
}
// Set the hash
$this->hash = $this->hash();
$this->loaded = true;
$this->getLog()->debug('Loaded ' . $this->layoutId . ' with hash ' . $this->hash . ', status ' . $this->status);
}
/**
* Load All Playlists
* @param array $options
* @throws NotFoundException
*/
public function loadPlaylists($options = [])
{
$allRegions = array_merge($this->regions, $this->drawers);
foreach ($allRegions as $region) {
/* @var Region $region */
$region->load($options);
}
}
/**
* Load Region Playlists
* @param array $options
* @throws NotFoundException
*/
public function loadDrawerPlaylists($options = [])
{
foreach ($this->drawers as $drawer) {
/* @var Region $region */
$drawer->load($options);
}
}
/**
* Load Drawer Playlists
* @param array $options
* @throws NotFoundException
*/
public function loadRegionPlaylists($options = [])
{
foreach ($this->regions as $region) {
/* @var Region $region */
$region->load($options);
}
}
/**
* Get this Layout's Campaign
* @return \Xibo\Entity\Campaign
* @throws \Xibo\Support\Exception\NotFoundException
*/
public function getCampaign()
{
return $this->campaignFactory->getById($this->campaignId);
}
/**
* Save this Layout
* @param array $options
* @throws GeneralException
*/
public function save($options = [])
{
// Default options
$options = array_merge([
'saveLayout' => true,
'saveRegions' => true,
'saveTags' => true,
'setBuildRequired' => true,
'validate' => true,
'notify' => true,
'audit' => true,
'import' => false,
'appendCountOnDuplicate' => false,
'setModifiedDt' => true,
'auditMessage' => 'Saved',
'type' => null
], $options);
if ($options['validate']) {
$this->validate($options);
}
if ($options['setBuildRequired']) {
$this->setBuildRequired();
}
$this->getLog()->debug('Saving ' . $this . ' with options ' . json_encode($options, JSON_PRETTY_PRINT));
// New or existing layout
if ($this->layoutId == null || $this->layoutId == 0) {
$this->add();
if ($options['audit']) {
if ($this->parentId === null) {
$this->audit(
$this->layoutId,
$options['auditMessage'] ?? 'Added',
[
'layoutId' => $this->layoutId,
'layout' => $this->layout,
'campaignId' => $this->campaignId,
]
);
} else {
$this->audit(
$this->layoutId,
$options['auditMessage'] ?? 'Checked out',
[
'layoutId' => $this->parentId,
'layout' => $this->layout,
'campaignId' => $this->campaignId,
]
);
}
}
} else if (($this->hash() != $this->hash && $options['saveLayout']) || $options['setBuildRequired']) {
$this->update($options);
if ($options['audit'] && count($this->getChangedProperties()) > 0) {
$change = $this->getChangedProperties();
$change['campaignId'][] = $this->campaignId;
if ($this->parentId === null) {
$this->audit($this->layoutId, $options['auditMessage'] ?? 'Updated', $change);
} else {
$this->audit($this->layoutId, $options['auditMessage'] ?? 'Updated Draft', $change);
}
}
} else {
$this->getLog()->info('Save layout properties unchanged for layoutId ' . $this->layoutId
. ', status = ' . $this->status);
}
if ($options['saveRegions']) {
$this->getLog()->debug('Saving Regions on ' . $this);
$allRegions = array_merge($this->regions, $this->drawers);
// Update all regions
foreach ($allRegions as $region) {
/* @var Region $region */
// Assert the Layout Id
$region->layoutId = $this->layoutId;
$region->save($options);
}
}
if ($options['saveTags']) {
$this->getLog()->debug('Saving tags on ' . $this);
// Remove unwanted ones
if (is_array($this->unlinkTags)) {
foreach ($this->unlinkTags as $tag) {
$this->unlinkTagFromEntity('lktaglayout', 'layoutId', $this->layoutId, $tag->tagId);
}
}
// Save the tags
if (is_array($this->linkTags)) {
foreach ($this->linkTags as $tag) {
$this->linkTagToEntity('lktaglayout', 'layoutId', $this->layoutId, $tag->tagId, $tag->value);
}
}
}
$this->getLog()->debug('Save finished for ' . $this);
}
/**
* Delete Layout
* @param array $options
* @throws GeneralException
*/
public function delete($options = [])
{
// We must ensure everything is loaded before we delete
if (!$this->loaded) {
$this->load();
}
$this->getLog()->debug('Deleting ' . $this);
// We cannot delete the default default
if ($this->layoutId == $this->config->getSetting('DEFAULT_LAYOUT')) {
throw new InvalidArgumentException(__('This layout is used as the global default and cannot be deleted'), 'layoutId');
}
// Delete our draft if we have one
// this is recursive, so be careful!
if ($this->parentId === null && $this->publishedStatusId === 2) {
try {
$draft = $this->layoutFactory->getByParentId($this->layoutId);
$draft->delete(['notify' => false]);
} catch (NotFoundException $notFoundException) {
$this->getLog()->info('No draft to delete for a Layout in the Draft state, odd!');
}
}
// Unassign all Tags
$this->unlinkAllTagsFromEntity('lktaglayout', 'layoutId', $this->layoutId);
$allRegions = array_merge($this->regions, $this->drawers);
// Delete Regions
foreach ($allRegions as $region) {
/* @var Region $region */
$region->delete($options);
}
// If we are the top level parent we also delete objects that sit on the top-level
if ($this->parentId === null) {
// Delete Permissions
foreach ($this->permissions as $permission) {
/* @var Permission $permission */
$permission->deleteAll();
}
// Delete widget history
$this->getStore()->update('DELETE FROM `widgethistory` WHERE layoutHistoryId IN (SELECT layoutHistoryId FROM `layouthistory` WHERE campaignId = :campaignId)', ['campaignId' => $this->campaignId]);
// Delete layout history
$this->getStore()->update('DELETE FROM `layouthistory` WHERE campaignId = :campaignId', ['campaignId' => $this->campaignId]);
// Unassign from all Campaigns
foreach ($this->campaigns as $campaign) {
/* @var Campaign $campaign */
$campaign->layouts = $this->layoutFactory->getByCampaignId($campaign->campaignId, false);
// Passing this layoutId without a display order will remove all occurrences.
// https://github.com/xibosignage/xibo/issues/1960
$campaign->unassignLayout($this->layoutId);
$campaign->save(['validate' => false]);
}
// Delete our own Campaign
$campaign = $this->campaignFactory->getById($this->campaignId);
$campaign->delete();
// Remove the Layout from any display defaults
$this->getStore()->update('UPDATE `display` SET defaultlayoutid = :defaultLayoutId WHERE defaultlayoutid = :layoutId', [
'layoutId' => $this->layoutId,
'defaultLayoutId' => $this->config->getSetting('DEFAULT_LAYOUT')
]);
// Remove any display group links
$this->getStore()->update('DELETE FROM `lklayoutdisplaygroup` WHERE layoutId = :layoutId', ['layoutId' => $this->layoutId]);
// Remove any display group links
$this->getStore()->update('DELETE FROM `schedule_sync` WHERE layoutId = :layoutId', ['layoutId' => $this->layoutId]);
} else {
// Remove the draft from any Campaign assignments
$this->getStore()->update('DELETE FROM `lkcampaignlayout` WHERE layoutId = :layoutId', ['layoutId' => $this->layoutId]);
}
foreach ($this->actions as $action) {
$action->delete();
}
// Remove the Layout (now it is orphaned it can be deleted safely)
$this->getStore()->update('DELETE FROM `layout` WHERE layoutid = :layoutId', array('layoutId' => $this->layoutId));
$this->getLog()->audit('Layout', $this->layoutId, 'Layout Deleted', ['layoutId' => $this->layoutId]);
// Delete the cached file (if there is one)
$this->deleteFiles();
// Audit the Delete
$this->audit($this->layoutId, 'Deleted' . (($this->parentId !== null) ? ' draft for ' . $this->parentId : ''));
}
/**
* Validate this layout
* @throws GeneralException
*/
public function validate($options)
{
// We must provide either a template or a resolution
if ($this->width == 0 || $this->height == 0) {
throw new InvalidArgumentException(__('The layout dimensions cannot be empty'), 'width/height');
}
// Validation
// Layout created from media follows the media character limit
if (empty($this->layout) || strlen($this->layout) > 100 || strlen($this->layout) < 1) {
throw new InvalidArgumentException(
__('Layout Name must be between 1 and 100 characters'),
'name'
);
}
if (!empty($this->description) && strlen($this->description) > 254) {
throw new InvalidArgumentException(
__('Description can not be longer than 254 characters'),
'description'
);
}
// Check for duplicates
// exclude our own duplicate (if we're a draft)
$duplicates = $this->layoutFactory->query(null, [
'userId' => $this->ownerId,
'layoutExact' => $this->layout,
'notLayoutId' => ($this->parentId !== null) ? $this->parentId : $this->layoutId,
'disableUserCheck' => 1,
'excludeTemplates' => -1
]);
$duplicateCount = count($duplicates);
if ($duplicateCount > 0) {
if ($options['appendCountOnDuplicate']) {
$this->layout = $this->layout . ' #' . ($duplicateCount + 1);
} else {
throw new DuplicateEntityException(sprintf(
__("You already own a Layout called '%s'. Please choose another name."),
$this->layout
));
}
}
// Check zindex is positive
if ($this->backgroundzIndex < 0) {
throw new InvalidArgumentException(__('Layer must be 0 or a positive number'), 'backgroundzIndex');
}
if ($this->code != null) {
if (!v::alnum('_')->validate($this->code)) {
throw new InvalidArgumentException(__('Please use only alphanumeric characters in Layout Code identifier', 'code'));
}
$duplicateCode = $this->layoutFactory->query(null, [
'notLayoutId' => ($this->parentId !== null) ? $this->parentId : $this->layoutId,
'disableUserCheck' => 1,
'excludeTemplates' => -1,
'retired' => -1,
'code' => $this->code
]);
if (count($duplicateCode) > 0) {
throw new DuplicateEntityException(__("Layout with provided code already exists"));
}
}
}
/**
* Add layout history
* this is called when a new Layout is added, and when a Draft Layout is published
* we can therefore expect to always have a Layout History record for a Layout
*/
private function addLayoutHistory()
{
$this->getLog()->debug('Adding Layout History record for ' . $this->layoutId);
// Add a record in layout history when a layout is added or published
$this->getStore()->insert('
INSERT INTO `layouthistory` (campaignId, layoutId, publishedDate)
VALUES (:campaignId, :layoutId, :publishedDate)
', [
'campaignId' => $this->campaignId,
'layoutId' => $this->layoutId,
'publishedDate' => Carbon::now()->format(DateFormatHelper::getSystemFormat())
]);
}
/**
* Add Widget History
* this should be called when the contents of a Draft Layout are destroyed during the publish process
* it preserves the current state of widgets before they are removed from the database
* that can then be used for proof of play stats, to get back to the original widget name/type and mediaId
* @param \Xibo\Entity\Layout $parent
* @throws NotFoundException
*/
private function addWidgetHistory($parent)
{
// Get the most recent layout history record
$layoutHistoryId = $this->getStore()->select('
SELECT layoutHistoryId FROM `layouthistory` WHERE layoutId = :layoutId
', [
'layoutId' => $parent->layoutId
]);
if (count($layoutHistoryId) <= 0) {
// We are missing the parent layout history record, which isn't good.
// I think all we can do at this stage is log it
$this->getLog()->alert('Missing Layout History for layoutId ' . $parent->layoutId . ' which is on campaignId ' . $parent->campaignId);
return;
}
$layoutHistoryId = intval($layoutHistoryId[0]['layoutHistoryId']);
// Add records in the widget history table representing all widgets on this Layout
foreach ($parent->getAllWidgets() as $widget) {
// Does this widget have a mediaId
$mediaId = null;
try {
$mediaId = $widget->getPrimaryMediaId();
} catch (NotFoundException $notFoundException) {
// this is fine
}
$this->getStore()->insert('
INSERT INTO `widgethistory` (layoutHistoryId, widgetId, mediaId, type, name)
VALUES (:layoutHistoryId, :widgetId, :mediaId, :type, :name);
', [
'layoutHistoryId' => $layoutHistoryId,
'widgetId' => $widget->widgetId,
'mediaId' => $mediaId,
'type' => $widget->type,
'name' => $widget->getOptionValue('name', null),
]);
}
}
/**
* Export the Layout as its XLF
* @return string
* @throws \Xibo\Support\Exception\GeneralException
* @throws \Xibo\Support\Exception\NotFoundException
*/
public function toXlf()
{
Profiler::start('Layout::toXlf', $this->getLog());
$this->getLog()->debug('Layout toXLF for Layout ' . $this->layout . ' - ' . $this->layoutId);
$this->load(['loadPlaylists' => true]);
// Keep track of whether this layout has an empty region
$this->hasEmptyRegion = false;
$layoutCountRegionsWithDuration = 0;
$document = new \DOMDocument();
$layoutNode = $document->createElement('layout');
$layoutNode->setAttribute('width', $this->width);
$layoutNode->setAttribute('height', $this->height);
$layoutNode->setAttribute('bgcolor', $this->backgroundColor);
$layoutNode->setAttribute('schemaVersion', $this->schemaVersion);
// add Layout code only if code identifier is set on the Layout.
if ($this->code != null) {
$layoutNode->setAttribute('code', $this->code);
}
// Layout stat collection flag
if (is_null($this->enableStat)) {
$layoutEnableStat = $this->config->getSetting('LAYOUT_STATS_ENABLED_DEFAULT');
$this->getLog()->debug('Layout enableStat is empty. Get the default setting.');
} else {
$layoutEnableStat = $this->enableStat;
}
$layoutNode->setAttribute('enableStat', $layoutEnableStat);
// Only set the z-index if present
if ($this->backgroundzIndex != 0) {
$layoutNode->setAttribute('zindex', $this->backgroundzIndex);
}
if ($this->backgroundImageId != 0) {
// Get stored as
$media = $this->mediaFactory->getById($this->backgroundImageId);
if ($media->released === 0) {
$this->pushStatusMessage(sprintf(
__('%s set as the Layout background image is pending conversion'),
$media->name
));
$this->status = Status::$STATUS_PLAYER;
} else if ($media->released === 2) {
$resizeLimit = $this->config->getSetting('DEFAULT_RESIZE_LIMIT');
$this->status = Status::$STATUS_INVALID;
throw new InvalidArgumentException(sprintf(
__('%s set as the Layout background image is too large. Please ensure that none of the images in your layout are larger than %s pixels on their longest edge. Please check the allowed Resize Limit in Administration -> Settings'),//@phpcs:ignore
$media->name,
$resizeLimit
), 'backgroundImageId');
}
$layoutNode->setAttribute('background', $media->storedAs);
}
$document->appendChild($layoutNode);
// Track module status within the layout
$status = 0;
$this->clearStatusMessage();
$layoutActionNode = null;
if (is_array($this->actions) && count($this->actions) > 0) {
// actions on Layout
foreach ($this->actions as $action) {
$layoutActionNode = $document->createElement('action');
$this->decorateActionXmlNode($layoutActionNode, $action);
$layoutNode->appendChild($layoutActionNode);
}
}
// merge regions and drawers into one array and go through it.
$allRegions = array_merge($this->regions, $this->drawers);
foreach ($allRegions as $region) {
/* @var Region $region */
// drawer
if ($region->isDrawer === 1) {
$regionNode = $document->createElement('drawer');
// normal region
} else {
$regionNode = $document->createElement('region');
}
$regionNode->setAttribute('id', $region->regionId);
$regionNode->setAttribute('width', $region->width);
$regionNode->setAttribute('height', $region->height);
$regionNode->setAttribute('top', $region->top);
$regionNode->setAttribute('left', $region->left);
$regionNode->setAttribute('syncKey', $region->syncKey ?? '');
// Only set the zIndex if present
if ($region->zIndex != 0) {
$regionNode->setAttribute('zindex', $region->zIndex);
}
$regionActionNode = null;
foreach ($region->actions as $action) {
$regionActionNode = $document->createElement('action');
$this->decorateActionXmlNode($regionActionNode, $action);
$regionNode->appendChild($regionActionNode);
}
$layoutNode->appendChild($regionNode);
// Region Duration
$region->duration = 0;
// Region Options
$regionOptionsNode = $document->createElement('options');
foreach ($region->regionOptions as $regionOption) {
$regionOptionNode = $document->createElement($regionOption->option, $regionOption->value ?? '');
$regionOptionsNode->appendChild($regionOptionNode);
}
$regionNode->appendChild($regionOptionsNode);
// Store region look to work out duration calc
$regionLoop = $region->getOptionValue('loop', 0);
// Canvas Regions
// --------------
// These are special regions containing multiple widgets which are all rendered by the same HTML.
// we should get the "global" widget inside this region and only add that to the XLF.
if ($region->type === 'canvas') {
$widget = null;
$widgetDuration = 0;
foreach ($region->getPlaylist()->setModuleFactory($this->moduleFactory)->widgets as $item) {
// Pull out the global widget, if we have one (we should)
if ($item->type === 'global') {
$widget = $item;
}
// Get the highest duration.
$widgetDuration = max($widgetDuration, $item->calculatedDuration);
// Validate all canvas widget properties.
$this->assessWidgetStatus($this->moduleFactory->getByType($item->type), $item, $status);
}
// If we don't have a global widget then we fail with an empty region
if ($widget === null) {
$widgets = [];
} else {
// Force use duration and pick the highest duration from inside.
$widget->useDuration = 1;
$widget->duration = $widgetDuration;
$widget->calculatedDuration = $widgetDuration;
// Add this widget only
$widgets = [$widget];
}
} else {
$widgets = $region->getPlaylist()->setModuleFactory($this->moduleFactory)->expandWidgets();
}
// Get a count of widgets in this region
$countWidgets = count($widgets);
// Check for empty Region, exclude Drawers from this check.
if ($countWidgets <= 0 && $region->isDrawer == 0) {
$this->getLog()->info('Layout has empty region - ' . $countWidgets . ' widgets. playlistId = '
. $region->getPlaylist()->getId());
$this->hasEmptyRegion = true;
}
// Work out if we have any "lead regions", those are Widgets with a duration
$maxWidgetDurationInLayout = 1;
foreach ($widgets as $widget) {
if (($widget->useDuration == 1 && $widget->type !== 'global')
|| $countWidgets > 1
|| $regionLoop == 1
|| $widget->type == 'video'
) {
$layoutCountRegionsWithDuration++;
}
$maxWidgetDurationInLayout = Max(
($widget->useDuration == 1 ? $widget->duration : $widget->calculatedDuration),
$maxWidgetDurationInLayout
);
}
foreach ($widgets as $widget) {
$module = $this->moduleFactory->getByType($widget->type);
// Set the Layout Status
$this->assessWidgetStatus($module, $widget, $status);
// Determine the duration of this widget
// the calculated duration contains the best guess at this duration from the playlist's perspective
// the only time we want to override this, is if we want it set to the Minimum Duration for the XLF
$widgetDuration = $widget->calculatedDuration;
// Is this Widget one that does not have a duration of its own?
// Assuming we have at least 1 region with a set duration, then we ought to
// Reset to the minimum duration
// do not do that if we are in the drawer Region!
if ($widget->useDuration == 0
&& $countWidgets <= 1
&& $regionLoop == 0
&& $widget->type != 'video'
&& $widget->type != 'videoin'
&& $layoutCountRegionsWithDuration >= 1
&& $region->isDrawer === 0
) {
// Make sure this Widget expires immediately so that the other Regions can be the leaders when
// it comes to expiring the Layout
// Only do this when the widget's default duration is not the max duration in layout
if ($widgetDuration < $maxWidgetDurationInLayout) {
$widgetDuration = Widget::$widgetMinDuration;
}
}
if ($region->isDrawer === 0) {
// Region duration
// If we have a cycle playback duration, we use that, otherwise we use the normal calculated
// duration.
$tempCyclePlaybackAverageDuration = $widget->getUnmatchedProperty(
'tempCyclePlaybackAverageDuration',
0
);
if ($tempCyclePlaybackAverageDuration) {
$region->duration = $region->duration + $tempCyclePlaybackAverageDuration;
} else {
$region->duration = $region->duration + $widgetDuration;
}
// We also want to add any transition OUT duration
// only the OUT duration because IN durations do not get added to the widget duration by the player
// https://github.com/xibosignage/xibo/issues/705
if ($widget->getOptionValue('transOut', '') != '') {
// Transition durations are in milliseconds
$region->duration = $region->duration + ($widget->getOptionValue('transOutDuration', 0) / 1000);
}
}
// Create media xml node for XLF.
$renderAs = $module->renderAs;
$mediaNode = $document->createElement('media');
$mediaNode->setAttribute('id', $widget->widgetId);
$mediaNode->setAttribute('schemaVersion', $widget->schemaVersion);
$mediaNode->setAttribute('type', $widget->type);
$mediaNode->setAttribute('render', ($renderAs == '') ? 'native' : $renderAs);
// to make the xml cleaner, add those nodes only on Widgets that were grouped in a subPlaylist Widget.
if (!empty($widget->tempId) && $widget->tempId != $widget->widgetId) {
$mediaNode->setAttribute('playlist', $widget->playlist);
$mediaNode->setAttribute('displayOrder', $widget->displayOrder);
// parentWidgetId is the Sub-playlist WidgetId,
// which is used to group all Widgets belonging to the same Sub-playlist
$mediaNode->setAttribute('parentWidgetId', $widget->tempId);
// These three attributes relate to cycle based playback
$mediaNode->setAttribute('isRandom', $widget->getOptionValue('isRandom', 0));
$mediaNode->setAttribute('playCount', $widget->getOptionValue('playCount', 0));
$mediaNode->setAttribute('cyclePlayback', $widget->getOptionValue('cyclePlayback', 0));
}
// Set the duration according to whether we are using widget duration or not
$isEndDetectVideoWidget = (
($widget->type === 'video' || $widget->type === 'audio')
&& $widget->useDuration === 0
);
$mediaNode->setAttribute('duration', ($isEndDetectVideoWidget ? 0 : $widgetDuration));
$mediaNode->setAttribute('useDuration', $widget->useDuration);
$widgetActionNode = null;
foreach ($widget->actions as $action) {
$widgetActionNode = $document->createElement('action');
$this->decorateActionXmlNode($widgetActionNode, $action);
$mediaNode->appendChild($widgetActionNode);
}
// Set a from/to date
if ($widget->fromDt != null || $widget->fromDt === Widget::$DATE_MIN) {
$mediaNode->setAttribute(
'fromDt',
Carbon::createFromTimestamp($widget->fromDt)->format(DateFormatHelper::getSystemFormat())
);
}
if ($widget->toDt != null || $widget->toDt === Widget::$DATE_MAX) {
$mediaNode->setAttribute(
'toDt',
Carbon::createFromTimestamp($widget->toDt)->format(DateFormatHelper::getSystemFormat())
);
}
// <editor-fold desc="Proof of Play stats collection">
// Logic Table
// -----------
// Widget With Media
// LAYOUT MEDIA WIDGET Media stats collected?
// ON ON ON YES Widget takes precedence // Match - 1
// ON OFF ON YES Widget takes precedence // Match - 1
// ON INHERIT ON YES Widget takes precedence // Match - 1
//
// OFF ON ON YES Widget takes precedence // Match - 1
// OFF OFF ON YES Widget takes precedence // Match - 1
// OFF INHERIT ON YES Widget takes precedence // Match - 1
//
// ON ON OFF NO Widget takes precedence // Match - 2
// ON OFF OFF NO Widget takes precedence // Match - 2
// ON INHERIT OFF NO Widget takes precedence // Match - 2
//
// OFF ON OFF NO Widget takes precedence // Match - 2
// OFF OFF OFF NO Widget takes precedence // Match - 2
// OFF INHERIT OFF NO Widget takes precedence // Match - 2
//
// ON ON INHERIT YES Media takes precedence // Match - 3
// ON OFF INHERIT NO Media takes precedence // Match - 4
// ON INHERIT INHERIT YES Media takes precedence and Inherited from Layout // Match - 5
//
// OFF ON INHERIT YES Media takes precedence // Match - 3
// OFF OFF INHERIT NO Media takes precedence // Match - 4
// OFF INHERIT INHERIT NO Media takes precedence and Inherited from Layout // Match - 6
//
// Widget Without Media
// LAYOUT WIDGET Widget stats collected?
// ON ON YES Widget takes precedence // Match - 1
// ON OFF NO Widget takes precedence // Match - 2
// ON INHERIT YES Inherited from Layout // Match - 7
// OFF ON YES Widget takes precedence // Match - 1
// OFF OFF NO Widget takes precedence // Match - 2
// OFF INHERIT NO Inherited from Layout // Match - 8
// Widget stat collection flag
$widgetEnableStat = $widget->getOptionValue(
'enableStat',
$this->config->getSetting('WIDGET_STATS_ENABLED_DEFAULT')
);
if ($widgetEnableStat === null || $widgetEnableStat === '') {
$widgetEnableStat = $this->config->getSetting('WIDGET_STATS_ENABLED_DEFAULT');
}
$enableStat = 0; // Match - 0
if ($widgetEnableStat == 'On') {
$enableStat = 1; // Match - 1
$this->getLog()->debug('For ' . $widget->widgetId . ': Layout '
. (($layoutEnableStat == 1) ? 'On': 'Off') . ' Widget '.$widgetEnableStat
. '. Media node output '. $enableStat);
} else if ($widgetEnableStat == 'Off') {
$enableStat = 0; // Match - 2
$this->getLog()->debug('For ' . $widget->widgetId . ': Layout '
. (($layoutEnableStat == 1) ? 'On': 'Off') . ' Widget ' . $widgetEnableStat
. '. Media node output '. $enableStat);
} else if ($widgetEnableStat == 'Inherit') {
try {
// Media enable stat flag - WIDGET WITH MEDIA
$media = $this->mediaFactory->getById($widget->getPrimaryMediaId());
if (empty($media->enableStat)) {
$mediaEnableStat = $this->config->getSetting('MEDIA_STATS_ENABLED_DEFAULT');
$this->getLog()->debug('Media enableStat is empty. Get the default setting.');
} else {
$mediaEnableStat = $media->enableStat;
}
if ($mediaEnableStat == 'On') {
$enableStat = 1; // Match - 3
} else if ($mediaEnableStat == 'Off') {
$enableStat = 0; // Match - 4
} else if ($mediaEnableStat == 'Inherit') {
$enableStat = $layoutEnableStat; // Match - 5 and 6
}
$this->getLog()->debug('For ' . $widget->widgetId . ': Layout '
. (($layoutEnableStat == 1) ? 'On': 'Off')
. ((isset($mediaEnableStat)) ? (' Media ' . $mediaEnableStat) : '')
. ' Widget '.$widgetEnableStat
. '. Media node output '. $enableStat);
} catch (\Exception $e) { // - WIDGET WITHOUT MEDIA
$this->getLog()->debug($widget->widgetId
. ' is not a library media and does not have a media id.');
$enableStat = $layoutEnableStat; // Match - 7 and 8
$this->getLog()->debug('For ' . $widget->widgetId . ': Layout '
. (($layoutEnableStat == 1) ? 'On': 'Off')
. ' Widget ' . $widgetEnableStat
. '. Media node output '. $enableStat);
}
}
// Set enable stat collection flag
$mediaNode->setAttribute('enableStat', $enableStat);
// </editor-fold>
// automatically set the transitions on the layout xml, we are not saving widgets here to avoid
// deadlock issues.
if ($this->autoApplyTransitions == 1) {
$widgetTransIn = $widget->getOptionValue(
'transIn',
$this->config->getSetting('DEFAULT_TRANSITION_IN')
);
$widgetTransOut = $widget->getOptionValue(
'transOut',
$this->config->getSetting('DEFAULT_TRANSITION_OUT')
);
$widgetTransInDuration = $widget->getOptionValue(
'transInDuration',
$this->config->getSetting('DEFAULT_TRANSITION_DURATION')
);
$widgetTransOutDuration = $widget->getOptionValue(
'transOutDuration',
$this->config->getSetting('DEFAULT_TRANSITION_DURATION')
);
$widget->setOptionValue('transIn', 'attrib', $widgetTransIn);
$widget->setOptionValue('transInDuration', 'attrib', $widgetTransInDuration);
$widget->setOptionValue('transOut', 'attrib', $widgetTransOut);
$widget->setOptionValue('transOutDuration', 'attrib', $widgetTransOutDuration);
}
// Create options nodes
$optionsNode = $document->createElement('options');
$rawNode = $document->createElement('raw');
$mediaNode->appendChild($optionsNode);
$mediaNode->appendChild($rawNode);
// Inject the URI
$uriInjected = false;
if ($module->regionSpecific == 0) {
$media = $this->mediaFactory->getById($widget->getPrimaryMediaId());
$optionNode = $document->createElement('uri', $media->storedAs);
$optionsNode->appendChild($optionNode);
$uriInjected = true;
// Add the fileId attribute to the media element
$mediaNode->setAttribute('fileId', $media->mediaId);
}
// Track whether we have an updateInterval configured.
$hasUpdatedInterval = false;
// Output all properties belonging to the module (we are not interested in templates because they
// are all HTML rendered)
$module->decorateProperties($widget, true, false);
foreach ($module->properties as $property) {
// We only output certain properties
if ($property->includeInXlf) {
if (($uriInjected && $property->id == 'uri') || empty($property->id)) {
// Skip any property named "uri" if we've already injected a special node for that.
// Skip properties without an id
continue;
}
// We have something to output
$optionNode = $document->createElement($property->id);
if ($property->isCData() && $property->value) {
$cdata = $document->createCDATASection($property->value);
$optionNode->appendChild($cdata);
// Add to the raw node
$rawNode->appendChild($optionNode);
} else {
$optionNode->nodeValue = $property->value ?? '';
// Add to the options node
$optionsNode->appendChild($optionNode);
}
}
if ($property->id === 'updateInterval') {
$hasUpdatedInterval = true;
}
}
// Handle common properties which are stored as options.
$this->getLog()->debug('toXlf: adding transtions to option nodes, widgetId: ' . $widget->widgetId);
$transIn = $widget->getOptionValue('transIn', null);
if (!empty($transIn)) {
$optionsNode->appendChild($document->createElement('transIn', $transIn));
$optionsNode->appendChild($document->createElement(
'transInDuration',
$widget->getOptionValue(
'transInDuration',
$this->config->getSetting('DEFAULT_TRANSITION_DURATION')
)
));
$optionsNode->appendChild($document->createElement(
'transInDirection',
$widget->getOptionValue('transInDirection', 'E')
));
}
$transOut = $widget->getOptionValue('transOut', null);
if (!empty($transOut)) {
$optionsNode->appendChild($document->createElement('transOut', $transOut));
$optionsNode->appendChild($document->createElement(
'transOutDuration',
$widget->getOptionValue(
'transOutDuration',
$this->config->getSetting('DEFAULT_TRANSITION_DURATION')
)
));
$optionsNode->appendChild($document->createElement(
'transOutDirection',
$widget->getOptionValue('transOutDirection', 'E')
));
}
// If we do not have an update interval, should we set a default one?
// https://github.com/xibosignage/xibo/issues/2319
if (!$hasUpdatedInterval && $module->regionSpecific == 1) {
// Modules/Widgets without an update interval update very infrequently
$optionsNode->appendChild(
$document->createElement('updateInterval', 1440 * 30)
);
}
// Handle associated audio
$audioNodes = null;
foreach ($widget->audio as $audio) {
/** @var WidgetAudio $audio */
if ($audioNodes == null) {
$audioNodes = $document->createElement('audio');
}
// Get the full media node for this audio element
$audioMedia = $this->mediaFactory->getById($audio->mediaId);
$audioNode = $document->createElement('uri', $audioMedia->storedAs);
$audioNode->setAttribute('volume', $audio->volume);
$audioNode->setAttribute('loop', $audio->loop);
$audioNode->setAttribute('mediaId', $audio->mediaId);
$audioNodes->appendChild($audioNode);
}
if ($audioNodes != null) {
$mediaNode->appendChild($audioNodes);
}
$regionNode->appendChild($mediaNode);
}
$this->getLog()->debug('Region duration on layout ' . $this->layoutId . ' is ' . $region->duration
. '. Comparing to ' . $this->duration);
// Track the max duration within the layout
// Test this duration against the layout duration
if ($this->duration < $region->duration) {
$this->duration = $region->duration;
}
$event = new LayoutBuildRegionEvent($region->regionId, $regionNode);
$this->getDispatcher()->dispatch($event, $event::NAME);
// End of region loop.
}
$this->getLog()->debug('Setting Layout Duration to ' . $this->duration);
$tagsNode = $document->createElement('tags');
foreach ($this->tags as $tag) {
/* @var Tag $tag */
$tagNode = $document->createElement('tag', $tag->tag . (!empty($tag->value) ? '|' . $tag->value : ''));
$tagsNode->appendChild($tagNode);
}
$layoutNode->appendChild($tagsNode);
// Update the layout status / duration accordingly
$this->status = ($status < $this->status) ? $status : $this->status;
// Fire a layout.build event, passing the layout and the generated document.
$event = new LayoutBuildEvent($this, $document);
$this->getDispatcher()->dispatch($event, $event::NAME);
Profiler::end('Layout::toXlf', $this->getLog());
return $document->saveXML();
}
/**
* Assess the status of the provided widget
* @param Module $module
* @param Widget $widget
* @param int $status
* @return void
*/
public function assessWidgetStatus(Module $module, Widget $widget, int &$status): void
{
$moduleStatus = Status::$STATUS_VALID;
try {
// Validate the module
$module
->decorateProperties($widget, true)
->validateProperties('status');
// Also validate the module template
$templateId = $widget->getOptionValue('templateId', null);
if ($templateId !== null && $templateId !== 'elements') {
$template = $this->moduleTemplateFactory->getByDataTypeAndId($module->dataType, $templateId);
$template
->decorateProperties($widget)
->validateProperties('status');
}
// If we have validator interfaces, then use it now
foreach ($module->getWidgetValidators() as $widgetValidator) {
$widgetValidator->validate($module, $widget, 'status');
}
// We need to make sure that all media in the widget have a valid release status
// Get all primary media IDs for this widget (audio IDs are excluded)
$mediaIds = $widget->getPrimaryMedia();
// Only validate if we actually have media IDs
if (!empty($mediaIds)) {
// Inspect each media item individually to validate its released status
foreach ($mediaIds as $mediaId) {
$media = $this->mediaFactory->getById($mediaId);
if ($media->released == 0) {
throw new GeneralException(sprintf(
__('%s is pending conversion'),
$media->name
));
} else if ($media->released == 2) {
if ($media->mediaType === 'image') {
throw new GeneralException(sprintf(
__('%s is too large. Please ensure that none of the images in your layout are larger than your Resize Limit on their longest edge.'),//phpcs:ignore
$media->name
));
} else {
throw new GeneralException(sprintf(
__('%s failed validation and cannot be published.'),
$media->name
));
}
}
}
}
// Is this a sub-playlist?
if ($module->type === 'subplaylist') {
$event = new SubPlaylistValidityEvent($widget);
$this->getDispatcher()->dispatch($event);
if (!$event->isValid()) {
throw new InvalidArgumentException(__('Misconfigured Playlist'), 'playlistId');
}
}
} catch (GeneralException $xiboException) {
$this->getLog()->debug('assessWidgetStatus: ' . $module->moduleId . ' invalid, e: '
. $xiboException->getMessage());
$moduleStatus = Status::$STATUS_INVALID;
// Include the exception on
$this->pushStatusMessage($xiboException->getMessage());
}
$status = ($moduleStatus > $status) ? $moduleStatus : $status;
}
/**
* @param \DOMElement $node
* @param Action $action
* @return void
*/
private function decorateActionXmlNode(\DOMElement $node, Action $action): void
{
$node->setAttribute('layoutCode', $action->layoutCode ?? '');
$node->setAttribute('target', $action->target ?? '');
$node->setAttribute('source', $action->source ?? '');
$node->setAttribute('actionType', $action->actionType ?? '');
$node->setAttribute('triggerType', $action->triggerType ?? '');
$node->setAttribute('triggerCode', $action->triggerCode ?? '');
$node->setAttribute('id', $action->actionId);
if (!empty($action->widgetId)) {
$node->setAttribute('widgetId', $action->widgetId);
}
if (!empty($action->targetId)) {
$node->setAttribute('targetId', $action->targetId);
}
if (!empty($action->sourceId)) {
$node->setAttribute('sourceId', $action->sourceId);
}
}
/**
* Export the Layout as a ZipArchive
* @param DataSetFactory $dataSetFactory
* @param \Xibo\Factory\WidgetDataFactory $widgetDataFactory
* @param string $fileName
* @param array $options
* @throws \Xibo\Support\Exception\GeneralException
*/
public function toZip(
DataSetFactory $dataSetFactory,
WidgetDataFactory $widgetDataFactory,
string $fileName,
array $options = []
): void {
$options = array_merge([
'includeData' => false,
'includeFallback' => false,
], $options);
// Load the complete layout
$this->load();
// We export to a ZIP file
$zip = new \ZipArchive();
$result = $zip->open($fileName, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
if ($result !== true) {
throw new InvalidArgumentException(__('Can\'t create ZIP. Error Code: ' . $result), 'fileName');
}
// Add a mapping file for the region names
$regionMapping = [];
foreach ($this->regions as $region) {
/** @var Region $region */
$regionMapping[$region->regionId] = $region->name;
}
// Add a mapping file for the drawer region names
$drawerMapping = [];
foreach ($this->drawers as $drawer) {
/** @var Region $region */
$drawerMapping[$drawer->regionId] = $drawer->name;
}
// Add layout information to the ZIP
$zip->addFromString('layout.json', json_encode([
'layout' => $this->layout,
'description' => $this->description,
'regions' => $regionMapping,
'drawers' => $drawerMapping,
'layoutDefinitions' => $this
]));
// Add all media
$libraryLocation = $this->config->getSetting('LIBRARY_LOCATION');
$mappings = [];
foreach ($this->mediaFactory->getByLayoutId($this->layoutId, 1, 1) as $media) {
/* @var Media $media */
$zip->addFile($libraryLocation . $media->storedAs, 'library/' . $media->fileName);
$media->load();
$mappings[] = [
'file' => $media->fileName,
'mediaid' => $media->mediaId,
'name' => $media->name,
'type' => $media->mediaType,
'duration' => $media->duration,
'background' => 0,
'font' => 0,
'tags' => $media->tags
];
}
// Add the background image
if ($this->backgroundImageId != 0) {
$media = $this->mediaFactory->getById($this->backgroundImageId);
$zip->addFile($libraryLocation . $media->storedAs, 'library/' . $media->fileName);
$media->load();
$mappings[] = [
'file' => $media->fileName,
'mediaid' => $media->mediaId,
'name' => $media->name,
'type' => $media->mediaType,
'duration' => $media->duration,
'background' => 1,
'font' => 0,
'tags' => $media->tags
];
}
if (file_exists($this->getThumbnailUri())) {
$zip->addFile($this->getThumbnailUri(), 'library/thumbs/campaign_thumb.png');
}
// Add any fonts
// Parse cdata/raw Widget Options (raw html, css, js etc)
// Get fonts assigned to elements
// lookup font files in db by name and add them to the zip
$fonts = [];
$nonElementsFonts = null;
foreach ($this->getAllWidgets() as $widget) {
foreach ($widget->widgetOptions as $option) {
if ($option->type === 'cdata' || $option->type === 'raw' && $option->option !== 'elements') {
preg_match_all('/font-family:(.*?);/', $option->value, $nonElementsFonts);
if (!empty($nonElementsFonts[1])) {
foreach ($nonElementsFonts[1] as $nonElementsFont) {
if (!in_array(trim($nonElementsFont), $fonts)) {
$fonts[] = trim($nonElementsFont);
}
}
}
} else if ($option->option === 'elements') {
$widgetElements = $widget->getOptionValue('elements', null);
// Elements will be JSON
$widgetElements = json_decode($widgetElements, true);
// go through the arrays to get properties array inside of elements
// find fontFamily property, add it to fonts array if we do not already have it there
foreach (($widgetElements ?? []) as $widgetElement) {
foreach (($widgetElement['elements'] ?? []) as $element) {
foreach ($element['properties'] as $property) {
if ($property['id'] === 'fontFamily' && !in_array($property['value'], $fonts)) {
$fonts[] = $property['value'];
}
}
}
}
}
}
}
if (!empty($fonts)) {
$this->getLog()->debug(sprintf('Matched fonts: %s', json_encode($fonts)));
foreach ($fonts as $font) {
$matches = $this->fontFactory->getByName($font);
if (count($matches) <= 0) {
$this->getLog()->info(sprintf('Unmatched font during export: %s', $font));
continue;
}
$fontFile = $matches[0];
$zip->addFile($libraryLocation . 'fonts/'. $fontFile->fileName, 'library/' . $fontFile->fileName);
$mappings[] = [
'file' => $fontFile->fileName,
'fontId' => $fontFile->id,
'name' => $fontFile->name,
'type' => 'font',
'background' => 0,
'font' => 1
];
}
}
// Add the mappings file to the ZIP
$zip->addFromString('mapping.json', json_encode($mappings));
// Handle any DataSet structures
$dataSetIds = [];
$dataSets = [];
// Handle any Widget Data
$widgetData = [];
// Playlists
$playlistMappings = [];
$playlistDefinitions = [];
$nestedPlaylistDefinitions = [];
foreach ($this->getAllWidgets() as $widget) {
if ($widget->type == 'dataset') {
$dataSetId = $widget->getOptionValue('dataSetId', 0);
if ($dataSetId != 0) {
if (in_array($dataSetId, $dataSetIds)) {
continue;
}
// Export the structure for this dataSet
$dataSet = $dataSetFactory->getById($dataSetId);
$dataSet->load();
// Are we also looking to export the data?
if ($options['includeData']) {
$dataSet->data = $dataSet->getData([], ['includeFormulaColumns' => false]);
}
$dataSetIds[] = $dataSet->dataSetId;
$dataSets[] = $dataSet;
}
} else if ($widget->type == 'subplaylist') {
$playlistItems = json_decode($widget->getOptionValue('subPlaylists', '[]'), true);
foreach ($playlistItems as $playlistItem) {
$count = 1;
$playlist = $this->playlistFactory->getById($playlistItem['playlistId']);
// include Widgets only for non dynamic Playlists #2392
$playlist->load(['loadWidgets' => !$playlist->isDynamic]);
if ($playlist->isDynamic === 0) {
$playlist->expandWidgets(0, false);
}
$playlistDefinitions[$playlist->playlistId] = $playlist;
// this is a recursive function, we are adding Playlist definitions,
// Playlist mappings and DataSets existing on the nested Playlist.
$playlist->generatePlaylistMapping(
$playlist->widgets,
$playlist->playlistId,
$playlistMappings,
$count,
$nestedPlaylistDefinitions,
$dataSetIds,
$dataSets,
$dataSetFactory,
$options['includeData']
);
}
}
// Handle fallback data?
if ($options['includeFallback'] == 1) {
$fallback = $widgetDataFactory->getByWidgetId($widget->widgetId);
if (count($fallback) > 0) {
$widgetData[$widget->widgetId] = $fallback;
}
}
}
// Add the mappings file to the ZIP
if ($dataSets != []) {
$zip->addFromString('dataSet.json', json_encode($dataSets, JSON_PRETTY_PRINT));
}
// Add widget data
if ($options['includeFallback'] == 1 && $widgetData != []) {
$zip->addFromString('fallback.json', json_encode($widgetData, JSON_PRETTY_PRINT));
}
// Add the Playlist definitions to the ZIP
if ($playlistDefinitions != []) {
$zip->addFromString('playlist.json', json_encode($playlistDefinitions, JSON_PRETTY_PRINT));
}
// Add the nested Playlist definitions to the ZIP
if ($nestedPlaylistDefinitions != []) {
$zip->addFromString('nestedPlaylist.json', json_encode($nestedPlaylistDefinitions, JSON_PRETTY_PRINT));
}
// Add Playlist mappings file to the ZIP
if ($playlistMappings != []) {
$zip->addFromString('playlistMappings.json', json_encode($playlistMappings, JSON_PRETTY_PRINT));
}
$zip->close();
}
/**
* Is a build of this layout required?
* @return bool
*/
public function isBuildRequired(): bool
{
return $this->status == 3 || !file_exists($this->getCachePath());
}
/**
* Has this Layout built this session?
* @return bool
*/
public function hasBuilt(): bool
{
return $this->hasBuilt;
}
/**
* Save the XLF to disk if necessary
* @param array $options
* @return string the path
* @throws InvalidArgumentException
* @throws NotFoundException
* @throws GeneralException
*/
public function xlfToDisk($options = [])
{
$options = array_merge([
'notify' => true,
'collectNow' => true,
'exceptionOnError' => false,
'exceptionOnEmptyRegion' => true,
'publishing' => false
], $options);
Profiler::start('Layout::xlfToDisk', $this->getLog());
$path = $this->getCachePath();
if ($this->status == 3 || !file_exists($path)) {
$this->getLog()->debug('XLF needs building for Layout ' . $this->layoutId);
$this->load(['loadPlaylists' => true]);
// Layout auto Publish
if ($this->config->getSetting('DEFAULT_LAYOUT_AUTO_PUBLISH_CHECKB') == 1 && $this->isChild()) {
// we are editing a draft layout, the published date is set on the original layout, therefore we
// need our parent.
$parent = $this->layoutFactory->loadById($this->parentId);
$layoutCurrentPublishedDate = Carbon::createFromTimestamp($parent->publishedDate);
$newPublishDateString = Carbon::now()->addMinutes(30)->format(DateFormatHelper::getSystemFormat());
$newPublishDate = Carbon::createFromTimeString($newPublishDateString);
if ($layoutCurrentPublishedDate > $newPublishDate) {
// Layout is set to Publish manually on a date further than 30 min from now, we don't touch it in
// this case.
$this->getLog()->debug('Layout is set to Publish manually on a date further than 30 min'
. ' from now, do not update');
} else if ($parent->publishedDate != null
&& $layoutCurrentPublishedDate < Carbon::now()->subMinutes(5)
) {
// Layout is set to Publish manually at least 5 min in the past at the moment, we expect the
// Regular Maintenance to build it before that happens
$this->getLog()->debug('Layout should be built by Regular Maintenance');
} else {
$parent->setPublishedDate($newPublishDateString);
$this->getLog()->debug('Layout set to automatically Publish on ' . $newPublishDateString);
}
}
// Assume error
$this->status = Status::$STATUS_INVALID;
// Reset duration
$this->duration = 0;
// Save the resulting XLF
try {
file_put_contents($path, $this->toXlf());
} catch (\Exception $e) {
$this->getLog()->error('Cannot build Layout ' . $this->layoutId . '. error: ' . $e->getMessage());
// Will continue and save the status as 4
$this->status = Status::$STATUS_INVALID;
if ($e->getMessage() != '') {
$this->pushStatusMessage($e->getMessage());
} else {
$this->pushStatusMessage('Unexpected Error');
}
// No need to notify on an errored build
$options['notify'] = false;
}
if ($options['exceptionOnError']) {
// Handle exception cases
if ($this->status === Status::$STATUS_INVALID
|| ($options['exceptionOnEmptyRegion'] && $this->hasEmptyRegion())
) {
$this->getLog()->debug('xlfToDisk: publish failed for layoutId ' . $this->layoutId
. ', status is ' . $this->status);
$this->audit($this->layoutId, 'Publish layout failed, rollback', ['layoutId' => $this->layoutId]);
throw new InvalidArgumentException(
sprintf(
__('There is an error with this Layout: %s'),
implode(',', $this->getStatusMessage())
),
'status'
);
}
}
// If we have an empty region, and we've not exceptioned, then we need to record that in our status
if ($this->hasEmptyRegion()) {
$this->status = Status::$STATUS_INVALID;
$this->pushStatusMessage(__('Empty Region'));
}
$this->save([
'saveRegions' => true,
'saveRegionOptions' => false,
'manageRegionAssignments' => false,
'saveTags' => false,
'setBuildRequired' => false,
'audit' => false,
'validate' => false,
'notify' => $options['notify'],
'collectNow' => $options['collectNow'],
'setModifiedDt' => false,
]);
$this->hasBuilt = true;
} else {
$this->getLog()->debug('xlfToDisk: no build required for layoutId: ' . $this->layoutId);
$this->hasBuilt = false;
}
Profiler::end('Layout::xlfToDisk', $this->getLog());
return $path;
}
/**
* @return string
*/
private function getCachePath()
{
$libraryLocation = $this->config->getSetting('LIBRARY_LOCATION');
return $libraryLocation . $this->layoutId . '.xlf';
}
/**
* Delete any cached files for this Layout.
*/
private function deleteFiles()
{
if (file_exists($this->getCachePath())) {
@unlink($this->getCachePath());
}
$libraryLocation = $this->config->getSetting('LIBRARY_LOCATION');
// Delete any thumbs
if (file_exists($libraryLocation . 'thumbs/' . $this->getId() . '_layout_thumb.png')) {
@unlink($libraryLocation . 'thumbs/' . $this->getId() . '_layout_thumb.png');
}
if (file_exists($libraryLocation . 'thumbs/' . $this->campaignId . '_campaign_thumb.png')) {
@unlink($libraryLocation . 'thumbs/' . $this->campaignId . '_campaign_thumb.png');
}
}
/**
* Publish the Draft
* @throws GeneralException
* @throws InvalidArgumentException
* @throws NotFoundException
*/
public function publishDraft()
{
$this->getLog()->debug('publish: publishing draft layoutId: ' . $this->layoutId . ', status: ' . $this->status);
// We are the draft - make sure we have a parent
if (!$this->isChild()) {
throw new InvalidArgumentException(__('Not a Draft'), 'statusId');
}
// Get my parent for later
$parent = $this->layoutFactory->loadById($this->parentId);
// I am the draft, so I clear my parentId, and set the parentId of my parent, to myself (swapping us)
// Make me the parent.
$this->getStore()->update('UPDATE `layout` SET parentId = NULL WHERE layoutId = :layoutId', [
'layoutId' => $this->layoutId
]);
// Set my parent, to be my child.
$this->getStore()->update('UPDATE `layout` SET parentId = :parentId WHERE layoutId = :layoutId', [
'parentId' => $this->layoutId,
'layoutId' => $this->parentId
]);
// clear publishedDate
$this->getStore()->update('UPDATE `layout` SET publishedDate = null WHERE layoutId = :layoutId', [
'layoutId' => $this->layoutId
]);
// Update any campaign links
$this->getStore()->update('
UPDATE `lkcampaignlayout`
SET layoutId = :layoutId
WHERE layoutId = :parentId
AND campaignId IN (SELECT campaignId FROM campaign WHERE isLayoutSpecific = 0)
', [
'parentId' => $this->parentId,
'layoutId' => $this->layoutId
]);
// Persist things that might have changed
// NOTE: permissions are managed on the campaign, so we do not need to worry.
$this->layout = $parent->layout;
$this->description = $parent->description;
$this->retired = $parent->retired;
$this->enableStat = $parent->enableStat;
$this->code = $parent->code;
$this->folderId = $parent->folderId;
// Swap all tags over, any changes we've made to the parents tags should be moved to the child.
$this->getStore()->update('UPDATE `lktaglayout` SET layoutId = :layoutId WHERE layoutId = :parentId', [
'parentId' => $parent->layoutId,
'layoutId' => $this->layoutId
]);
// Update any Displays which use this as their default Layout
$this->getStore()->update('UPDATE `display` SET defaultLayoutId = :layoutId WHERE defaultLayoutId = :parentId', [
'parentId' => $parent->layoutId,
'layoutId' => $this->layoutId
]);
// Swap any display group links
$this->getStore()->update('UPDATE `lklayoutdisplaygroup` SET layoutId = :layoutId WHERE layoutId = :parentId', [
'layoutId' => $this->layoutId,
'parentId' => $parent->layoutId
]);
// swap any schedule_sync links
$this->getStore()->update('UPDATE `schedule_sync` SET layoutId = :layoutId WHERE layoutId = :parentId', [
'layoutId' => $this->layoutId,
'parentId' => $parent->layoutId
]);
// If this is the global default layout, then add some special handling to make sure we swap the default over
// to the incoming draft
if ($this->parentId == $this->config->getSetting('DEFAULT_LAYOUT')) {
// Change it over to me.
$this->config->changeSetting('DEFAULT_LAYOUT', $this->layoutId);
}
// Preserve the widget information
$this->addWidgetHistory($parent);
// Publish thumbnails.
$this->publishThumbnail();
// Delete the parent (make sure we set the parent to be a child of us, otherwise we will delete the linked
// campaign
$parent->parentId = $this->layoutId;
$parent->tags = []; // Clear the tags so we don't attempt a delete.
$parent->permissions = []; // Clear the permissions so we don't attempt a delete
$parent->delete();
// Set my statusId to published
// we do not want to notify here as we should wait for the build to happen
$this->publishedStatusId = 1;
$this->save([
'saveLayout' => true,
'saveRegions' => false,
'saveTags' => false,
'setBuildRequired' => false,
'validate' => false,
'audit' => true,
'notify' => false
]);
// Nullify my parentId (I no longer have a parent)
$this->parentId = null;
// Add a layout history
$this->addLayoutHistory();
// Always rebuild for a publish
$this->status = 3;
}
public function setPublishedDate($publishedDate)
{
$this->publishedDate = $publishedDate;
$this->getStore()->update('UPDATE `layout` SET publishedDate = :publishedDate WHERE layoutId = :layoutId', [
'layoutId' => $this->layoutId,
'publishedDate' => $this->publishedDate
]);
}
/**
* Discard the Draft
* @throws GeneralException
*/
public function discardDraft(bool $isShouldUpdateParent = true)
{
// We are the draft - make sure we have a parent
if (!$this->isChild()) {
$this->getLog()->debug('Cant discard draft ' . $this->layoutId . '. publishedStatusId = ' . $this->publishedStatusId . ', parentId = ' . $this->parentId);
throw new InvalidArgumentException(__('Not a Draft'), 'statusId');
}
// We just need to delete ourselves really
$this->delete();
// We also need to update the parent so that it is no longer draft
if ($isShouldUpdateParent) {
$parent = $this->layoutFactory->getById($this->parentId);
$parent->publishedStatusId = 1;
$parent->save([
self::$saveOptionsMinimum
]);
}
}
//
// Add / Update
//
/**
* Add
* @throws GeneralException
*/
private function add()
{
$this->getLog()->debug('Adding Layout ' . $this->layout);
$sql = 'INSERT INTO layout (layout, description, userID, createdDT, modifiedDT, publishedStatusId, status, width, height, schemaVersion, backgroundImageId, backgroundColor, backgroundzIndex, parentId, enableStat, retired, duration, autoApplyTransitions, code)
VALUES (:layout, :description, :userid, :createddt, :modifieddt, :publishedStatusId, :status, :width, :height, :schemaVersion, :backgroundImageId, :backgroundColor, :backgroundzIndex, :parentId, :enableStat, 0, 0, :autoApplyTransitions, :code)';
$time = Carbon::now()->format(DateFormatHelper::getSystemFormat());
$this->layoutId = $this->getStore()->insert($sql, array(
'layout' => $this->layout,
'description' => $this->description,
'userid' => $this->ownerId,
'createddt' => $time,
'modifieddt' => $time,
'publishedStatusId' => $this->publishedStatusId, // Default to 1 (published)
'status' => 3,
'width' => $this->width,
'height' => $this->height,
'schemaVersion' => Environment::$XLF_VERSION,
'backgroundImageId' => $this->backgroundImageId,
'backgroundColor' => $this->backgroundColor,
'backgroundzIndex' => $this->backgroundzIndex,
'parentId' => ($this->parentId == null) ? null : $this->parentId,
'enableStat' => $this->enableStat,
'autoApplyTransitions' => ($this->autoApplyTransitions == null) ? 0 : $this->autoApplyTransitions,
'code' => ($this->code == null) ? null : $this->code
));
// Add a Campaign
// we do not add a campaign record for draft layouts.
if ($this->parentId === null) {
$campaign = $this->campaignFactory->create(
$this->getUnmatchedProperty('type', 'list'),
$this->layout,
$this->getOwnerId(),
($this->folderId == null) ? 1 : $this->folderId
);
$campaign->isLayoutSpecific = 1;
$campaign->cyclePlaybackEnabled = 0;
$campaign->listPlayOrder = 'round';
// check that the user has access to the folder we're adding them to
$folder = $this->folderFactory->getById($campaign->folderId, 0);
$campaign->permissionsFolderId = $folder->getPermissionFolderIdOrThis();
// Assign the layout
$campaign->assignLayout($this->layoutId);
// Ready to save the Campaign
// adding a Layout Specific Campaign shouldn't ever notify (it can't hit anything because we've only
// just added it)
$campaign->save([
'notify' => false
]);
// Assign the new campaignId to this layout
$this->campaignId = $campaign->campaignId;
// Add a layout history
$this->addLayoutHistory();
} else if ($this->campaignId == null) {
throw new InvalidArgumentException(__('Draft Layouts must have a parent'), 'campaignId');
} else {
// Add this draft layout as a link to the campaign
$campaign = $this->campaignFactory->getById($this->campaignId);
$campaign->layouts = $this->layoutFactory->getByCampaignId($campaign->campaignId, false);
$campaign->assignLayout($this->layoutId);
$campaign->save([
'notify' => false
]);
}
}
/**
* Update
* @param array $options
* @throws GeneralException
*/
private function update($options = [])
{
$options = array_merge([
'notify' => true,
'collectNow' => true,
], $options);
$this->getLog()->debug('Editing Layout ' . $this->layout . '. Id = ' . $this->layoutId);
$sql = '
UPDATE layout
SET layout = :layout,
description = :description,
duration = :duration,
modifiedDT = :modifieddt,
retired = :retired,
width = :width,
height = :height,
backgroundImageId = :backgroundImageId,
backgroundColor = :backgroundColor,
backgroundzIndex = :backgroundzIndex,
`status` = :status,
publishedStatusId = :publishedStatusId,
`userId` = :userId,
`schemaVersion` = :schemaVersion,
`statusMessage` = :statusMessage,
enableStat = :enableStat,
autoApplyTransitions = :autoApplyTransitions,
code = :code
WHERE layoutID = :layoutid
';
// Only set the modified date if requested.
$time = ($options['setModifiedDt'])
? Carbon::now()->format(DateFormatHelper::getSystemFormat())
: $this->modifiedDt;
$this->getStore()->update($sql, array(
'layoutid' => $this->layoutId,
'layout' => $this->layout,
'description' => $this->description,
'duration' => ($this->duration == null) ? 0 : $this->duration,
'modifieddt' => $time,
'retired' => ($this->retired == null) ? 0 : $this->retired,
'width' => $this->width,
'height' => $this->height,
'backgroundImageId' => ($this->backgroundImageId == null) ? null : $this->backgroundImageId,
'backgroundColor' => $this->backgroundColor,
'backgroundzIndex' => $this->backgroundzIndex,
'status' => $this->status,
'publishedStatusId' => $this->publishedStatusId,
'userId' => $this->ownerId,
'schemaVersion' => ($this->schemaVersion == null) ? Environment::$XLF_VERSION : $this->schemaVersion,
'statusMessage' => (empty($this->statusMessage)) ? null : json_encode($this->statusMessage),
'enableStat' => $this->enableStat,
'autoApplyTransitions' => $this->autoApplyTransitions,
'code' => ($this->code == null) ? null : $this->code
));
// Update the Campaign
if ($this->parentId === null) {
$campaign = $this->campaignFactory->getById($this->campaignId);
$campaign->campaign = $this->layout;
$campaign->ownerId = $this->ownerId;
$campaign->folderId = $this->folderId;
// if user has disabled folder feature, presumably said user also has no permissions to folder
// getById would fail here and prevent adding new Layout in web ui
try {
$folder = $this->folderFactory->getById($campaign->folderId);
$campaign->permissionsFolderId = ($folder->getPermissionFolderId() == null) ? $folder->id : $folder->getPermissionFolderId();
} catch (NotFoundException $exception) {
$campaign->permissionsFolderId = 1;
}
$campaign->save(['validate' => false, 'notify' => $options['notify'], 'collectNow' => $options['collectNow'], 'layoutCode' => $this->code]);
}
}
/**
* Handle the Playlist closure table for specified Layout object
*
* @throws InvalidArgumentException
* @throws NotFoundException
*/
public function managePlaylistClosureTable()
{
// we only need to set the closure table records for the playlists assigned directly to the regionPlaylist here
// all other relations between Playlists themselves are handled on import before layout is created
// as the SQL we run here is recursive everything will end up with correct parent/child relation and depth level
foreach ($this->getAllWidgets() as $widget) {
if ($widget->type == 'subplaylist') {
$assignedPlaylistIds = [];
$assignedPlaylists = json_decode($widget->getOptionValue('subPlaylists', '[]'), true);
foreach ($assignedPlaylists as $subPlaylistItem) {
if (!in_array($subPlaylistItem['playlistId'], $assignedPlaylistIds)) {
$assignedPlaylistIds[] = $subPlaylistItem['playlistId'];
}
}
foreach ($this->regions as $region) {
$regionPlaylist = $region->regionPlaylist;
if ($widget->playlistId == $regionPlaylist->playlistId) {
$parentId = $regionPlaylist->playlistId;
$child = $assignedPlaylistIds;
}
}
if (isset($parentId) && isset($child)) {
foreach ($child as $childId) {
$this->getLog()->debug(
'Manage closure table for parent ' . $parentId . ' and child ' . $childId
);
if ($this->getStore()->exists('SELECT parentId, childId, depth FROM lkplaylistplaylist WHERE childId = :childId AND parentId = :parentId ', [//phpcs:ignore
'parentId' => $parentId,
'childId' => $childId
])) {
throw new InvalidArgumentException(
__('Cannot add the same SubPlaylist twice.'),
'playlistId'
);
}
$this->getStore()->insert('
INSERT INTO `lkplaylistplaylist` (parentId, childId, depth)
SELECT p.parentId, c.childId, p.depth + c.depth + 1
FROM lkplaylistplaylist p, lkplaylistplaylist c
WHERE p.childId = :parentId AND c.parentId = :childId
', [
'parentId' => $parentId,
'childId' => $childId
]);
}
}
}
}
}
/**
* This function will adjust the Action sourceId and targetId in all relevant objects in our imported Layout
* @param bool $validate
* @throws InvalidArgumentException
* @throws NotFoundException
*/
public function manageActions(bool $validate = true): void
{
$oldRegionIds = [];
$newRegionIds = [];
$newWidgetIds = [];
$oldWidgetIds = [];
// get all regionIds including drawers
$allNewRegions = array_merge($this->regions, $this->drawers);
// create an array of new and old (from import) Region and Widget ids
/** @var Region $region */
foreach ($allNewRegions as $region) {
$newRegionIds[] = $region->regionId;
$oldRegionIds[] = $region->tempId;
/** @var Widget $widget */
foreach ($region->getPlaylist()->widgets as $widget) {
$newWidgetIds[] = $widget->widgetId;
$oldWidgetIds[] = $widget->tempWidgetId;
}
}
// combine the arrays into $old=>$new key value arrays
$combined = array_combine($oldRegionIds, $newRegionIds);
$combinedWidgets = array_combine($oldWidgetIds, $newWidgetIds);
// get Actions with Layout
$layoutActions = $this->actionFactory->query(null, ['source' => 'importLayout']);
// go through all imported actions on a Layout and replace the source/target Ids with the new ones
foreach ($layoutActions as $action) {
// If the action targets the old layout ID, update it so the action now targets the new layout ID
if ($action->targetId == $action->layoutId) {
$action->targetId = $this->layoutId;
}
$action->source = 'layout';
$action->sourceId = $this->layoutId;
$action->layoutId = $this->layoutId;
if ($action->targetId != null) {
foreach ($combined as $old => $new) {
if ($old == $action->targetId) {
$this->getLog()->debug('Layout Import, switching Layout Action target ID from ' . $old . ' to ' . $new);
$action->targetId = $new;
}
}
}
// switch widgetId
if ($action->widgetId != null) {
foreach ($combinedWidgets as $old => $new) {
if ($old == $action->widgetId && $action->actionType == 'navWidget') {
$this->getLog()->debug('Layout Import, switching Widget Action widget ID from ' . $old . ' to ' . $new);
$action->widgetId = $new;
}
}
}
$action->save(['validate' => $validate]);
}
// Actions with Region
$regionActions = $this->actionFactory->query(null, ['source' => 'importRegion']);
// go through all imported actions on a Region and replace the source/target Ids with the new ones
foreach ($regionActions as $action) {
// If the action targets the old layout ID, update it so the action now targets the new layout ID
if ($action->targetId == $action->layoutId) {
$action->targetId = $this->layoutId;
}
$action->source = 'region';
$action->layoutId = $this->layoutId;
foreach ($combined as $old => $new) {
if ($old == $action->targetId) {
$this->getLog()->debug('Layout Import, switching Region Action target ID from ' . $old . ' to ' . $new);
$action->targetId = $new;
}
if ($action->sourceId === $old) {
$this->getLog()->debug('Layout Import, switching Region Action source ID from ' . $old . ' to ' . $new);
$action->sourceId = $new;
}
}
// switch widgetId
if ($action->widgetId != null) {
foreach ($combinedWidgets as $old => $new) {
if ($old == $action->widgetId && $action->actionType == 'navWidget') {
$this->getLog()->debug('Layout Import, switching Widget Action widget ID from ' . $old . ' to ' . $new);
$action->widgetId = $new;
}
}
}
$action->save(['validate' => $validate]);
}
// Actions with Widget
$widgetActions = $this->actionFactory->query(null, ['source' => 'importWidget']);
// go through all imported actions on a Widget and replace the source/target Ids with the new ones
foreach ($widgetActions as $action) {
// If the action targets the old layout ID, update it so the action now targets the new layout ID
if ($action->targetId == $action->layoutId) {
$action->targetId = $this->layoutId;
}
$action->source = 'widget';
$action->layoutId = $this->layoutId;
// switch Action source Id and Action widget Id
foreach ($combinedWidgets as $old => $new) {
if ($action->sourceId == $old) {
$this->getLog()->debug('Layout Import, switching Widget Action source ID from ' . $old . ' to ' . $new);
$action->sourceId = $new;
}
if ($action->widgetId != null) {
if ($old == $action->widgetId && $action->actionType == 'navWidget') {
$this->getLog()->debug('Layout Import, switching Widget Action widget ID from ' . $old . ' to ' . $new);
$action->widgetId = $new;
}
}
}
// if we had targetId (regionId) then switch it
if ($action->targetId != null) {
foreach ($combined as $old => $new) {
if ($old == $action->targetId) {
$this->getLog()->debug('Layout Import, switching Widget Action target ID from ' . $old . ' to ' . $new);
$action->targetId = $new;
}
}
}
$action->save(['validate' => $validate]);
}
// Make sure we update targetRegionId in Drawer Widgets.
foreach ($allNewRegions as $region) {
foreach ($region->getPlaylist()->widgets as $widget) {
if ($region->isDrawer === 1) {
foreach ($combined as $old => $new) {
if ($widget->getOptionValue('targetRegionId', null) == $old) {
$this->getLog()->debug('Layout Import, switching Widget targetRegionId from ' . $old . ' to ' . $new);
$widget->setOptionValue('targetRegionId', 'attrib', $new);
$widget->save();
}
}
}
}
}
}
/**
* Adjust source and target id in copied Layout (checkout / copy )
*
* @param Layout $newLayout
* @param Layout $originalLayout
* @param bool $validate
* @throws InvalidArgumentException
* @throws NotFoundException
*/
public function copyActions(Layout $newLayout, Layout $originalLayout, bool $validate = true)
{
$oldRegionIds = [];
$newRegionIds = [];
$oldWidgetIds = [];
$newWidgetIds = [];
$this->getLog()->debug('Copy Actions from ' . $originalLayout->layoutId . ' To ' . $newLayout->layoutId);
/** @var Region[] $allRegions */
$allRegions = array_merge($newLayout->regions, $newLayout->drawers);
// go through all layouts, regions, playlists and their widgets
/** @var Region $region */
foreach ($allRegions as $region) {
// Match our original region id to the id in the parent layout
$original = $originalLayout->getRegionOrDrawer($region->getOriginalValue('regionId'));
$oldRegionIds[] = (int)$original->regionId;
$newRegionIds[] = $region->regionId;
foreach ($region->getPlaylist()->widgets as $widget) {
$originalWidget = $original->getPlaylist()->getWidget($widget->getOriginalValue('widgetId'));
$oldWidgetIds[] = (int)$originalWidget->widgetId;
$newWidgetIds[] = $widget->widgetId;
}
}
// create $old=>$new arrays of all of them to later update the Actions
$combinedRegionIds = array_combine($oldRegionIds, $newRegionIds);
$combinedWidgetIds = array_combine($oldWidgetIds, $newWidgetIds);
$this->getLog()->debug('Region Ids array ' . json_encode($combinedRegionIds));
$this->getLog()->debug('Widget Ids array ' . json_encode($combinedWidgetIds));
// Interactive Actions on Layout
foreach ($newLayout->actions as $action) {
// switch source Id
if ($action->sourceId === $originalLayout->layoutId) {
$action->sourceId = $newLayout->layoutId;
}
// switch layoutId
if ($action->layoutId === $originalLayout->layoutId) {
$action->layoutId = $newLayout->layoutId;
}
// if action target (screen) was old layout, update with new id
if ($action->targetId === $originalLayout->layoutId && $action->target == 'screen') {
$action->targetId = $newLayout->layoutId;
}
// if we had targetId (regionId) then switch it
if ($action->targetId != null) {
foreach ($combinedRegionIds as $old => $new) {
if ($old == $action->targetId) {
$action->targetId = $new;
}
}
}
// switch Action widgetId
if ($action->widgetId != null) {
foreach ($combinedWidgetIds as $old => $new) {
if ($old == $action->widgetId && $action->actionType == 'navWidget') {
$action->widgetId = $new;
}
}
}
$action->save(['validate' => $validate]);
}
// Region Actions
foreach ($allRegions as $region) {
// Match our original region id to the id in the parent layout
$original = $originalLayout->getRegionOrDrawer($region->getOriginalValue('regionId'));
// Interactive Actions on Region
foreach ($region->actions as $action) {
// switch source Id
if ($action->sourceId === $original->regionId) {
$action->sourceId = $region->regionId;
}
// switch layoutId
if ($action->layoutId === $originalLayout->layoutId) {
$action->layoutId = $newLayout->layoutId;
}
// if action target (screen) was old layout, update with new id
if ($action->targetId === $originalLayout->layoutId && $action->target == 'screen') {
$action->targetId = $newLayout->layoutId;
}
// if we had targetId (regionId) then switch it
if ($action->targetId != null) {
foreach ($combinedRegionIds as $old => $new) {
if ($old == $action->targetId) {
$action->targetId = $new;
}
}
}
// switch Action widgetId
if ($action->widgetId != null) {
foreach ($combinedWidgetIds as $old => $new) {
if ($old == $action->widgetId && $action->actionType == 'navWidget') {
$action->widgetId = $new;
}
}
}
$action->save(['validate' => $validate]);
}
// Widget Actions
foreach ($region->getPlaylist()->widgets as $widget) {
$originalWidget = $original->getPlaylist()->getWidget($widget->getOriginalValue('widgetId'));
// Make sure we update targetRegionId in Drawer Widgets on checkout.
if ($region->isDrawer === 1) {
foreach ($combinedRegionIds as $old => $new) {
if ($widget->getOptionValue('targetRegionId', null) == $old) {
$widget->setOptionValue('targetRegionId', 'attrib', $new);
$widget->save();
}
}
}
// Interactive Actions on Widget
foreach ($widget->actions as $action) {
// switch source Id
if ($action->sourceId === $originalWidget->widgetId) {
$action->sourceId = $widget->widgetId;
}
// switch layoutId
if ($action->layoutId === $originalLayout->layoutId) {
$action->layoutId = $newLayout->layoutId;
}
// if action target (screen) was old layout, update with new id
if ($action->targetId === $originalLayout->layoutId && $action->target == 'screen') {
$action->targetId = $newLayout->layoutId;
}
// if we had targetId (regionId) then switch it
if ($action->targetId != null) {
foreach ($combinedRegionIds as $old => $new) {
if ($old == $action->targetId) {
$action->targetId = $new;
}
}
}
// switch Action widgetId
if ($action->widgetId != null) {
foreach ($combinedWidgetIds as $old => $new) {
if ($old == $action->widgetId && $action->actionType == 'navWidget') {
$action->widgetId = $new;
}
}
}
$action->save(['validate' => $validate]);
}
}
}
}
/**
* @return string
*/
public function getThumbnailUri(): string
{
$libraryLocation = $this->config->getSetting('LIBRARY_LOCATION');
if ($this->isChild()) {
return $libraryLocation . 'thumbs/' . $this->campaignId . '_layout_thumb.png';
} else {
return $libraryLocation . 'thumbs/' . $this->campaignId . '_campaign_thumb.png';
}
}
/**
* Publish the Layout thumbnail if it exists.
*/
private function publishThumbnail()
{
$libraryLocation = $this->config->getSetting('LIBRARY_LOCATION');
if (file_exists($libraryLocation . 'thumbs/' . $this->campaignId . '_layout_thumb.png')) {
copy(
$libraryLocation . 'thumbs/' . $this->campaignId . '_layout_thumb.png',
$libraryLocation . 'thumbs/' . $this->campaignId . '_campaign_thumb.png'
);
}
}
}