Files

807 lines
30 KiB
PHP
Raw Permalink Normal View History

2025-12-02 10:32:59 -05:00
<?php
/*
* Copyright (C) 2025 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\Controller;
use Parsedown;
use Psr\Http\Message\ResponseInterface;
use Slim\Http\Response as Response;
use Slim\Http\ServerRequest as Request;
use Xibo\Entity\SearchResult;
use Xibo\Entity\SearchResults;
use Xibo\Event\TemplateProviderEvent;
use Xibo\Event\TemplateProviderListEvent;
use Xibo\Factory\LayoutFactory;
use Xibo\Factory\TagFactory;
use Xibo\Support\Exception\AccessDeniedException;
use Xibo\Support\Exception\GeneralException;
use Xibo\Support\Exception\InvalidArgumentException;
use Xibo\Support\Exception\NotFoundException;
/**
* Class Template
* @package Xibo\Controller
*/
class Template extends Base
{
/**
* @var LayoutFactory
*/
private $layoutFactory;
/**
* @var TagFactory
*/
private $tagFactory;
/**
* @var \Xibo\Factory\ResolutionFactory
*/
private $resolutionFactory;
/**
* Set common dependencies.
* @param LayoutFactory $layoutFactory
* @param TagFactory $tagFactory
* @param \Xibo\Factory\ResolutionFactory $resolutionFactory
*/
public function __construct($layoutFactory, $tagFactory, $resolutionFactory)
{
$this->layoutFactory = $layoutFactory;
$this->tagFactory = $tagFactory;
$this->resolutionFactory = $resolutionFactory;
}
/**
* Display page logic
* @param Request $request
* @param Response $response
* @return \Psr\Http\Message\ResponseInterface|Response
* @throws GeneralException
* @throws \Xibo\Support\Exception\ControllerNotImplemented
*/
function displayPage(Request $request, Response $response)
{
// Call to render the template
$this->getState()->template = 'template-page';
return $this->render($request, $response);
}
/**
* Data grid
*
* @SWG\Get(
* path="/template",
* operationId="templateSearch",
* tags={"template"},
* summary="Template Search",
* description="Search templates this user has access to",
* @SWG\Response(
* response=200,
* description="successful operation",
* @SWG\Schema(
* type="array",
* @SWG\Items(ref="#/definitions/Layout")
* )
* )
* )
* @param Request $request
* @param Response $response
* @return \Psr\Http\Message\ResponseInterface|Response
* @throws GeneralException
* @throws \Xibo\Support\Exception\ControllerNotImplemented
* @throws \Xibo\Support\Exception\NotFoundException
*/
public function grid(Request $request, Response $response)
{
$sanitizedQueryParams = $this->getSanitizer($request->getQueryParams());
// Embed?
$embed = ($sanitizedQueryParams->getString('embed') != null)
? explode(',', $sanitizedQueryParams->getString('embed'))
: [];
$templates = $this->layoutFactory->query($this->gridRenderSort($sanitizedQueryParams), $this->gridRenderFilter([
'excludeTemplates' => 0,
'tags' => $sanitizedQueryParams->getString('tags'),
'layoutId' => $sanitizedQueryParams->getInt('templateId'),
'layout' => $sanitizedQueryParams->getString('template'),
'useRegexForName' => $sanitizedQueryParams->getCheckbox('useRegexForName'),
'folderId' => $sanitizedQueryParams->getInt('folderId'),
'logicalOperator' => $sanitizedQueryParams->getString('logicalOperator'),
'logicalOperatorName' => $sanitizedQueryParams->getString('logicalOperatorName'),
], $sanitizedQueryParams));
foreach ($templates as $template) {
/* @var \Xibo\Entity\Layout $template */
if (in_array('regions', $embed)) {
$template->load([
'loadPlaylists' => in_array('playlists', $embed),
'loadCampaigns' => in_array('campaigns', $embed),
'loadPermissions' => in_array('permissions', $embed),
'loadTags' => in_array('tags', $embed),
'loadWidgets' => in_array('widgets', $embed)
]);
}
if ($this->isApi($request)) {
continue;
}
$template->includeProperty('buttons');
// Thumbnail
$template->setUnmatchedProperty('thumbnail', '');
if (file_exists($template->getThumbnailUri())) {
$template->setUnmatchedProperty(
'thumbnail',
$this->urlFor($request, 'layout.download.thumbnail', ['id' => $template->layoutId])
);
}
// Parse down for description
$template->setUnmatchedProperty(
'descriptionWithMarkup',
Parsedown::instance()->setSafeMode(true)->text($template->description),
);
if ($this->getUser()->featureEnabled('template.modify')
&& $this->getUser()->checkEditable($template)
) {
// Design Button
$template->buttons[] = [
'id' => 'layout_button_design',
'linkType' => '_self', 'external' => true,
'url' => $this->urlFor(
$request,
'layout.designer',
['id' => $template->layoutId]
) . '?isTemplateEditor=1',
'text' => __('Alter Template')
];
if ($template->isEditable()) {
$template->buttons[] = ['divider' => true];
$template->buttons[] = array(
'id' => 'layout_button_publish',
'url' => $this->urlFor($request, 'layout.publish.form', ['id' => $template->layoutId]),
'text' => __('Publish')
);
$template->buttons[] = array(
'id' => 'layout_button_discard',
'url' => $this->urlFor($request, 'layout.discard.form', ['id' => $template->layoutId]),
'text' => __('Discard')
);
$template->buttons[] = ['divider' => true];
} else {
$template->buttons[] = ['divider' => true];
// Checkout Button
$template->buttons[] = array(
'id' => 'layout_button_checkout',
'url' => $this->urlFor($request, 'layout.checkout.form', ['id' => $template->layoutId]),
'text' => __('Checkout'),
'dataAttributes' => [
['name' => 'auto-submit', 'value' => true],
[
'name' => 'commit-url',
'value' => $this->urlFor(
$request,
'layout.checkout',
['id' => $template->layoutId]
)
],
['name' => 'commit-method', 'value' => 'PUT']
]
);
$template->buttons[] = ['divider' => true];
}
// Edit Button
$template->buttons[] = array(
'id' => 'layout_button_edit',
'url' => $this->urlFor($request, 'template.edit.form', ['id' => $template->layoutId]),
'text' => __('Edit')
);
// Select Folder
if ($this->getUser()->featureEnabled('folder.view')) {
$template->buttons[] = [
'id' => 'campaign_button_selectfolder',
'url' => $this->urlFor($request, 'campaign.selectfolder.form', ['id' => $template->campaignId]),
'text' => __('Select Folder'),
'multi-select' => true,
'dataAttributes' => [
[
'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')],
['name' => 'rowtitle', 'value' => $template->layout],
['name' => 'form-callback', 'value' => 'moveFolderMultiSelectFormOpen']
]
];
}
// Copy Button
$template->buttons[] = array(
'id' => 'layout_button_copy',
'url' => $this->urlFor($request, 'layout.copy.form', ['id' => $template->layoutId]),
'text' => __('Copy')
);
}
// Extra buttons if have delete permissions
if ($this->getUser()->featureEnabled('template.modify')
&& $this->getUser()->checkDeleteable($template)) {
// Delete Button
$template->buttons[] = [
'id' => 'layout_button_delete',
'url' => $this->urlFor($request, 'layout.delete.form', ['id' => $template->layoutId]),
'text' => __('Delete'),
'multi-select' => true,
'dataAttributes' => [
[
'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')],
['name' => 'sort-group', 'value' => 1],
['name' => 'rowtitle', 'value' => $template->layout]
]
];
}
$template->buttons[] = ['divider' => true];
// Extra buttons if we have modify permissions
if ($this->getUser()->featureEnabled('template.modify')
&& $this->getUser()->checkPermissionsModifyable($template)) {
// Permissions button
$template->buttons[] = [
'id' => 'layout_button_permissions',
'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-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' => 'content-id-name', 'value' => 'campaignId']
]
];
}
if ($this->getUser()->featureEnabled('layout.export')) {
$template->buttons[] = ['divider' => true];
// Export Button
$template->buttons[] = array(
'id' => 'layout_button_export',
'linkType' => '_self',
'external' => true,
'url' => $this->urlFor($request, 'layout.export', ['id' => $template->layoutId]),
'text' => __('Export')
);
}
}
$this->getState()->template = 'grid';
$this->getState()->recordsTotal = $this->layoutFactory->countLast();
$this->getState()->setData($templates);
return $this->render($request, $response);
}
/**
* Data grid
*
* @SWG\Get(
* path="/template/search",
* operationId="templateSearchAll",
* tags={"template"},
* summary="Template Search All",
* description="Search all templates from local and connectors",
* @SWG\Response(
* response=200,
* description="successful operation",
* @SWG\Schema(
* type="array",
* @SWG\Items(ref="#/definitions/SearchResult")
* )
* )
* )
* @param Request $request
* @param Response $response
* @return \Psr\Http\Message\ResponseInterface|Response
* @throws \Xibo\Support\Exception\GeneralException
*/
public function search(Request $request, Response $response)
{
$sanitizedQueryParams = $this->getSanitizer($request->getQueryParams());
$provider = $sanitizedQueryParams->getString('provider', ['default' => 'both']);
$searchResults = new SearchResults();
if ($provider === 'both' || $provider === 'local') {
$templates = $this->layoutFactory->query(['layout'], $this->gridRenderFilter([
'excludeTemplates' => 0,
'layout' => $sanitizedQueryParams->getString('template'),
'folderId' => $sanitizedQueryParams->getInt('folderId'),
'orientation' => $sanitizedQueryParams->getString('orientation', ['defaultOnEmptyString' => true]),
'publishedStatusId' => 1
], $sanitizedQueryParams));
foreach ($templates as $template) {
$searchResult = new SearchResult();
$searchResult->id = $template->layoutId;
$searchResult->source = 'local';
$searchResult->title = $template->layout;
// Handle the description
$searchResult->description = '';
if (!empty($template->description)) {
$searchResult->description = Parsedown::instance()->setSafeMode(true)->line($template->description);
}
$searchResult->orientation = $template->orientation;
$searchResult->width = $template->width;
$searchResult->height = $template->height;
if (!empty($template->tags)) {
foreach ($template->getTags() as $tag) {
if ($tag->tag === 'template') {
continue;
}
$searchResult->tags[] = $tag->tag;
}
}
// Thumbnail
$searchResult->thumbnail = '';
if (file_exists($template->getThumbnailUri())) {
$searchResult->thumbnail = $this->urlFor(
$request,
'layout.download.thumbnail',
['id' => $template->layoutId]
);
}
$searchResults->data[] = $searchResult;
}
}
if ($provider === 'both' || $provider === 'remote') {
// Hand off to any other providers that may want to provide results.
$event = new TemplateProviderEvent(
$searchResults,
$sanitizedQueryParams->getInt('start', ['default' => 0]),
$sanitizedQueryParams->getInt('length', ['default' => 15]),
$sanitizedQueryParams->getString('template'),
$sanitizedQueryParams->getString('orientation'),
);
$this->getLog()->debug('Dispatching event. ' . $event->getName());
try {
$this->getDispatcher()->dispatch($event, $event->getName());
} catch (\Exception $exception) {
$this->getLog()->error('Template search: Exception in dispatched event: ' . $exception->getMessage());
$this->getLog()->debug($exception->getTraceAsString());
}
}
return $response->withJson($searchResults);
}
/**
* Template Form
* @param Request $request
* @param Response $response
* @param $id
* @return \Psr\Http\Message\ResponseInterface|Response
* @throws AccessDeniedException
* @throws GeneralException
* @throws \Xibo\Support\Exception\ControllerNotImplemented
* @throws \Xibo\Support\Exception\NotFoundException
*/
function addTemplateForm(Request $request, Response $response, $id)
{
// Get the layout
$layout = $this->layoutFactory->getById($id);
// Check Permissions
if (!$this->getUser()->checkViewable($layout)) {
throw new AccessDeniedException(__('You do not have permissions to view this layout'));
}
$this->getState()->template = 'template-form-add-from-layout';
$this->getState()->setData([
'layout' => $layout,
]);
return $this->render($request, $response);
}
/**
* Add a Template
* @SWG\Post(
* path="/template",
* operationId="templateAdd",
* tags={"template"},
* summary="Add a Template",
* description="Add a new Template to the CMS",
* @SWG\Parameter(
* name="name",
* in="formData",
* description="The layout name",
* type="string",
* required=true
* ),
* @SWG\Parameter(
* name="description",
* in="formData",
* description="The layout description",
* type="string",
* required=false
* ),
* @SWG\Parameter(
* name="resolutionId",
* in="formData",
* description="If a Template is not provided, provide the resolutionId for this Layout.",
* type="integer",
* required=false
* ),
* @SWG\Parameter(
* name="returnDraft",
* in="formData",
* description="Should we return the Draft Layout or the Published Layout on Success?",
* type="boolean",
* required=false
* ),
* @SWG\Response(
* response=201,
* description="successful operation",
* @SWG\Schema(ref="#/definitions/Layout"),
* @SWG\Header(
* header="Location",
* description="Location of the new record",
* type="string"
* )
* )
* )
*
* @param Request $request
* @param Response $response
* @return \Psr\Http\Message\ResponseInterface|Response
* @throws GeneralException
* @throws InvalidArgumentException
* @throws NotFoundException
* @throws \Xibo\Support\Exception\ControllerNotImplemented
*/
function add(Request $request, Response $response)
{
$sanitizedParams = $this->getSanitizer($request->getParams());
$name = $sanitizedParams->getString('name');
$description = $sanitizedParams->getString('description');
$resolutionId = $sanitizedParams->getInt('resolutionId');
$enableStat = $sanitizedParams->getCheckbox('enableStat');
$autoApplyTransitions = $sanitizedParams->getCheckbox('autoApplyTransitions');
$folderId = $sanitizedParams->getInt('folderId');
if ($folderId === 1) {
$this->checkRootFolderAllowSave();
}
if (empty($folderId) || !$this->getUser()->featureEnabled('folder.view')) {
$folderId = $this->getUser()->homeFolderId;
}
// Tags
if ($this->getUser()->featureEnabled('tag.tagging')) {
$tags = $this->tagFactory->tagsFromString($sanitizedParams->getString('tags'));
} else {
$tags = [];
}
$tags[] = $this->tagFactory->tagFromString('template');
$layout = $this->layoutFactory->createFromResolution($resolutionId,
$this->getUser()->userId,
$name,
$description,
$tags,
null
);
// Set layout enableStat flag
$layout->enableStat = $enableStat;
// Set auto apply transitions flag
$layout->autoApplyTransitions = $autoApplyTransitions;
// Set folderId
$layout->folderId = $folderId;
// Save
$layout->save();
// Automatically checkout the new layout for edit
$layout = $this->layoutFactory->checkoutLayout($layout, $sanitizedParams->getCheckbox('returnDraft'));
// Return
$this->getState()->hydrate([
'httpStatus' => 201,
'message' => sprintf(__('Added %s'), $layout->layout),
'id' => $layout->layoutId,
'data' => $layout
]);
return $this->render($request, $response);
}
/**
* Add template
* @param Request $request
* @param Response $response
* @param $id
* @return \Psr\Http\Message\ResponseInterface|Response
* @throws AccessDeniedException
* @throws GeneralException
* @throws \Xibo\Support\Exception\ControllerNotImplemented
* @throws \Xibo\Support\Exception\NotFoundException
* @SWG\Post(
* path="/template/{layoutId}",
* operationId="template.add.from.layout",
* tags={"template"},
* summary="Add a template from a Layout",
* description="Use the provided layout as a base for a new template",
* @SWG\Parameter(
* name="layoutId",
* in="path",
* description="The Layout ID",
* type="integer",
* required=true
* ),
* @SWG\Parameter(
* name="includeWidgets",
* in="formData",
* description="Flag indicating whether to include the widgets in the Template",
* type="integer",
* required=true
* ),
* @SWG\Parameter(
* name="name",
* in="formData",
* description="The Template Name",
* type="string",
* required=true
* ),
* @SWG\Parameter(
* name="tags",
* in="formData",
* description="Comma separated list of Tags for the template",
* type="string",
* required=false
* ),
* @SWG\Parameter(
* name="description",
* in="formData",
* description="A description of the Template",
* type="string",
* required=false
* ),
* @SWG\Response(
* response=201,
* description="successful operation",
* @SWG\Schema(ref="#/definitions/Layout"),
* @SWG\Header(
* header="Location",
* description="Location of the new record",
* type="string"
* )
* )
* )
*/
public function addFromLayout(Request $request, Response $response, $id): Response
{
// Get the layout
$layout = $this->layoutFactory->getById($id);
// Check Permissions
if (!$this->getUser()->checkViewable($layout)) {
throw new AccessDeniedException(__('You do not have permissions to view this layout'));
}
$sanitizedParams = $this->getSanitizer($request->getParams());
// Should the copy include the widgets
$includeWidgets = ($sanitizedParams->getCheckbox('includeWidgets') == 1);
// Load without anything
$layout->load([
'loadPlaylists' => true,
'loadWidgets' => $includeWidgets,
'playlistIncludeRegionAssignments' => false,
'loadTags' => false,
'loadPermissions' => false,
'loadCampaigns' => false
]);
$originalLayout = $layout;
$layout = clone $layout;
$layout->layout = $sanitizedParams->getString('name');
if ($this->getUser()->featureEnabled('tag.tagging')) {
$layout->updateTagLinks($this->tagFactory->tagsFromString($sanitizedParams->getString('tags')));
} else {
$layout->tags = [];
}
$layout->assignTag($this->tagFactory->tagFromString('template'));
$layout->description = $sanitizedParams->getString('description');
$layout->folderId = $sanitizedParams->getInt('folderId');
if ($layout->folderId === 1) {
$this->checkRootFolderAllowSave();
}
// When saving a layout as a template, we should not include the empty canva region as that requires
// a widget to be inside it.
// https://github.com/xibosignage/xibo/issues/3574
if (!$includeWidgets) {
$this->getLog()->debug('addFromLayout: widgets have not been included, checking for empty regions');
$regionsWithWidgets = [];
foreach ($layout->regions as $region) {
if ($region->type === 'canvas') {
$this->getLog()->debug('addFromLayout: Canvas region excluded from export');
} else {
$regionsWithWidgets[] = $region;
}
}
$layout->regions = $regionsWithWidgets;
}
$layout->setOwner($this->getUser()->userId, true);
$layout->save();
if ($includeWidgets) {
// Sub-Playlist
foreach ($layout->regions as $region) {
// Match our original region id to the id in the parent layout
$original = $originalLayout->getRegion($region->getOriginalValue('regionId'));
// Make sure Playlist closure table from the published one are copied over
$original->getPlaylist()->cloneClosureTable($region->getPlaylist()->playlistId);
}
}
// Return
$this->getState()->hydrate([
'httpStatus' => 201,
'message' => sprintf(__('Saved %s'), $layout->layout),
'id' => $layout->layoutId,
'data' => $layout
]);
return $this->render($request, $response);
}
/**
* Displays an Add/Edit form
* @param Request $request
* @param Response $response
* @return \Psr\Http\Message\ResponseInterface|Response
* @throws GeneralException
* @throws \Xibo\Support\Exception\ControllerNotImplemented
*/
function addForm(Request $request, Response $response)
{
$this->getState()->template = 'template-form-add';
$this->getState()->setData([
'resolutions' => $this->resolutionFactory->query(['resolution']),
]);
return $this->render($request, $response);
}
/**
* Edit form
* @param Request $request
* @param Response $response
* @param $id
* @return \Psr\Http\Message\ResponseInterface|Response
* @throws AccessDeniedException
* @throws GeneralException
* @throws NotFoundException
* @throws \Xibo\Support\Exception\ControllerNotImplemented
*/
public function editForm(Request $request, Response $response, $id)
{
// Get the layout
$template = $this->layoutFactory->getById($id);
// Check Permissions
if (!$this->getUser()->checkEditable($template)) {
throw new AccessDeniedException();
}
$this->getState()->template = 'template-form-edit';
$this->getState()->setData([
'layout' => $template,
]);
return $this->render($request, $response);
}
/**
* Get list of Template 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 TemplateProviderListEvent();
$this->getDispatcher()->dispatch($event, $event->getName());
$providers = $event->getProviders();
return $response->withJson($providers);
}
}