[BUGFIX] Use pageTree.backgroundColor recursively
[Packages/TYPO3.CMS.git] / typo3 / sysext / backend / Classes / Controller / Page / TreeController.php
1 <?php
2 declare(strict_types = 1);
3 namespace TYPO3\CMS\Backend\Controller\Page;
4
5 /*
6 * This file is part of the TYPO3 CMS project.
7 *
8 * It is free software; you can redistribute it and/or modify it under
9 * the terms of the GNU General Public License, either version 2
10 * of the License, or any later version.
11 *
12 * For the full copyright and license information, please read the
13 * LICENSE.txt file that was distributed with this source code.
14 *
15 * The TYPO3 project - inspiring people to share!
16 */
17
18 use Psr\Http\Message\ResponseInterface;
19 use Psr\Http\Message\ServerRequestInterface;
20 use TYPO3\CMS\Backend\Configuration\BackendUserConfiguration;
21 use TYPO3\CMS\Backend\Tree\Repository\PageTreeRepository;
22 use TYPO3\CMS\Backend\Utility\BackendUtility;
23 use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
24 use TYPO3\CMS\Core\Database\ConnectionPool;
25 use TYPO3\CMS\Core\Http\JsonResponse;
26 use TYPO3\CMS\Core\Imaging\Icon;
27 use TYPO3\CMS\Core\Imaging\IconFactory;
28 use TYPO3\CMS\Core\Type\Bitmask\JsConfirmation;
29 use TYPO3\CMS\Core\Type\Bitmask\Permission;
30 use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
31 use TYPO3\CMS\Core\Utility\GeneralUtility;
32 use TYPO3\CMS\Frontend\Page\PageRepository;
33 use TYPO3\CMS\Workspaces\Service\WorkspaceService;
34
35 /**
36 * Controller providing data to the page tree
37 */
38 class TreeController
39 {
40 /**
41 * Option to use the nav_title field for outputting in the tree items, set via userTS.
42 *
43 * @var bool
44 */
45 protected $useNavTitle = false;
46
47 /**
48 * Option to prefix the page ID when outputting the tree items, set via userTS.
49 *
50 * @var bool
51 */
52 protected $addIdAsPrefix = false;
53
54 /**
55 * Option to prefix the domain name of sys_domains when outputting the tree items, set via userTS.
56 *
57 * @var bool
58 */
59 protected $addDomainName = false;
60
61 /**
62 * Option to add the rootline path above each mount point, set via userTS.
63 *
64 * @var bool
65 */
66 protected $showMountPathAboveMounts = false;
67
68 /**
69 * An array of background colors for a branch in the tree, set via userTS.
70 *
71 * @var array
72 */
73 protected $backgroundColors = [];
74
75 /**
76 * A list of pages not to be shown.
77 *
78 * @var array
79 */
80 protected $hiddenRecords = [];
81
82 /**
83 * Contains the state of all items that are expanded.
84 *
85 * @var array
86 */
87 protected $expandedState = [];
88
89 /**
90 * Associative array containing all pageIds as key, and domain names as values.
91 *
92 * @var array|null
93 */
94 protected $domains = null;
95
96 /**
97 * Instance of the icon factory, to be used for generating the items.
98 *
99 * @var IconFactory
100 */
101 protected $iconFactory;
102
103 /**
104 * Constructor to set up common objects needed in various places.
105 */
106 public function __construct()
107 {
108 $this->iconFactory = GeneralUtility::makeInstance(IconFactory::class);
109 $this->useNavTitle = (bool)$this->getBackendUser()->getTSConfigVal('options.pageTree.showNavTitle');
110 }
111
112 /**
113 * Returns page tree configuration in JSON
114 *
115 * @return ResponseInterface
116 */
117 public function fetchConfigurationAction(): ResponseInterface
118 {
119 $configuration = [
120 'allowRecursiveDelete' => !empty($this->getBackendUser()->uc['recursiveDelete']),
121 'doktypes' => $this->getDokTypes(),
122 'displayDeleteConfirmation' => $this->getBackendUser()->jsConfirmation(JsConfirmation::DELETE),
123 'temporaryMountPoint' => $this->getMountPointPath((int)($this->getBackendUser()->uc['pageTree_temporaryMountPoint'] ?? 0)),
124 ];
125
126 return new JsonResponse($configuration);
127 }
128
129 /**
130 * Returns the list of doktypes to display in page tree toolbar drag area
131 *
132 * Note: The list can be filtered by the user TypoScript
133 * option "options.pageTree.doktypesToShowInNewPageDragArea".
134 *
135 * @return array
136 */
137 protected function getDokTypes(): array
138 {
139 $doktypeLabelMap = [];
140 foreach ($GLOBALS['TCA']['pages']['columns']['doktype']['config']['items'] as $doktypeItemConfig) {
141 if ($doktypeItemConfig[1] === '--div--') {
142 continue;
143 }
144 $doktypeLabelMap[$doktypeItemConfig[1]] = $doktypeItemConfig[0];
145 }
146 $doktypes = GeneralUtility::intExplode(',', $this->getBackendUser()->getTSConfigVal('options.pageTree.doktypesToShowInNewPageDragArea'), true);
147 $output = [];
148 $allowedDoktypes = GeneralUtility::intExplode(',', $this->getBackendUser()->groupData['pagetypes_select'], true);
149 $isAdmin = $this->getBackendUser()->isAdmin();
150 // Early return if backend user may not create any doktype
151 if (!$isAdmin && empty($allowedDoktypes)) {
152 return $output;
153 }
154 foreach ($doktypes as $doktype) {
155 if (!$isAdmin && !in_array($doktype, $allowedDoktypes, true)) {
156 continue;
157 }
158 $label = htmlspecialchars($GLOBALS['LANG']->sL($doktypeLabelMap[$doktype]));
159 $output[] = [
160 'nodeType' => $doktype,
161 'icon' => $GLOBALS['TCA']['pages']['ctrl']['typeicon_classes'][$doktype] ?? '',
162 'title' => $label,
163 'tooltip' => $label
164 ];
165 }
166 return $output;
167 }
168
169 /**
170 * Returns JSON representing page tree
171 *
172 * @param ServerRequestInterface $request
173 * @return ResponseInterface
174 */
175 public function fetchDataAction(ServerRequestInterface $request): ResponseInterface
176 {
177 $this->hiddenRecords = GeneralUtility::intExplode(',', $this->getBackendUser()->getTSConfigVal('options.hideRecords.pages'), true);
178 $this->backgroundColors = $this->getBackendUser()->getTSConfigProp('options.pageTree.backgroundColor');
179 $this->addIdAsPrefix = (bool)$this->getBackendUser()->getTSConfigVal('options.pageTree.showPageIdWithTitle');
180 $this->addDomainName = (bool)$this->getBackendUser()->getTSConfigVal('options.pageTree.showDomainNameWithTitle');
181 $this->showMountPathAboveMounts = (bool)$this->getBackendUser()->getTSConfigVal('options.pageTree.showPathAboveMounts');
182 $backendUserConfiguration = GeneralUtility::makeInstance(BackendUserConfiguration::class);
183 $this->expandedState = $backendUserConfiguration->get('BackendComponents.States.Pagetree');
184 if (is_object($this->expandedState->stateHash)) {
185 $this->expandedState = (array)$this->expandedState->stateHash;
186 } else {
187 $this->expandedState = $this->expandedState['stateHash'] ?: [];
188 }
189
190 // Fetching a part of a pagetree
191 if (!empty($request->getQueryParams()['pid'])) {
192 $entryPoints = [(int)$request->getQueryParams()['pid']];
193 } else {
194 $entryPoints = $this->getAllEntryPointPageTrees();
195 }
196 $items = [];
197 foreach ($entryPoints as $page) {
198 $items = array_merge($items, $this->pagesToFlatArray($page, (int)$page['uid']));
199 }
200
201 return new JsonResponse($items);
202 }
203
204 /**
205 * Sets a temporary mount point
206 *
207 * @param ServerRequestInterface $request
208 * @return ResponseInterface
209 * @throws \RuntimeException
210 */
211 public function setTemporaryMountPointAction(ServerRequestInterface $request): ResponseInterface
212 {
213 if (empty($request->getParsedBody()['pid'])) {
214 throw new \RuntimeException(
215 'Required "pid" parameter is missing.',
216 1511792197
217 );
218 }
219 $pid = (int)$request->getParsedBody()['pid'];
220
221 $this->getBackendUser()->uc['pageTree_temporaryMountPoint'] = $pid;
222 $this->getBackendUser()->writeUC();
223 $response = [
224 'mountPointPath' => $this->getMountPointPath($pid)
225 ];
226 return new JsonResponse($response);
227 }
228
229 /**
230 * Converts nested tree structure produced by PageTreeRepository to a flat, one level array
231 * and also adds visual representation information to the data.
232 *
233 * @param array $page
234 * @param int $entryPoint
235 * @param int $depth
236 * @param array $inheritedData
237 * @return array
238 */
239 protected function pagesToFlatArray(array $page, int $entryPoint, int $depth = 0, array $inheritedData = []): array
240 {
241 $pageId = (int)$page['uid'];
242 if (in_array($pageId, $this->hiddenRecords, true)) {
243 return [];
244 }
245
246 $stopPageTree = $page['php_tree_stop'] && $depth > 0;
247 $identifier = $entryPoint . '_' . $pageId;
248 $expanded = $page['expanded'] || (isset($this->expandedState[$identifier]) && $this->expandedState[$identifier]);
249 $backgroundColor = $this->backgroundColors[$pageId] ?: ($inheritedData['backgroundColor'] ?? '');
250
251 $suffix = '';
252 $prefix = '';
253 $nameSourceField = 'title';
254 $visibleText = $page['title'];
255 $tooltip = BackendUtility::titleAttribForPages($page, '', false);
256 if ($pageId !== 0) {
257 $icon = $this->iconFactory->getIconForRecord('pages', $page, Icon::SIZE_SMALL);
258 } else {
259 $icon = $this->iconFactory->getIcon('apps-pagetree-root', Icon::SIZE_SMALL);
260 }
261
262 if ($this->useNavTitle && trim($page['nav_title'] ?? '') !== '') {
263 $nameSourceField = 'nav_title';
264 $visibleText = $page['nav_title'];
265 }
266 if (trim($visibleText) === '') {
267 $visibleText = htmlspecialchars('[' . $GLOBALS['LANG']->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.no_title') . ']');
268 }
269 $visibleText = GeneralUtility::fixed_lgd_cs($visibleText, (int)$this->getBackendUser()->uc['titleLen'] ?: 40);
270
271 if ($this->addDomainName) {
272 $domain = $this->getDomainNameForPage($pageId);
273 $suffix = $domain !== '' ? ' [' . $domain . ']' : '';
274 }
275
276 $lockInfo = BackendUtility::isRecordLocked('pages', $pageId);
277 if (is_array($lockInfo)) {
278 $tooltip .= ' - ' . $lockInfo['msg'];
279 }
280 if ($this->addIdAsPrefix) {
281 $prefix = htmlspecialchars('[' . $pageId . '] ');
282 }
283
284 $items = [];
285 $items[] = [
286 // Used to track if the tree item is collapsed or not
287 'stateIdentifier' => $identifier,
288 'identifier' => $pageId,
289 'depth' => $depth,
290 'tip' => htmlspecialchars($tooltip),
291 'hasChildren' => !empty($page['_children']),
292 'icon' => $icon->getIdentifier(),
293 'name' => $visibleText,
294 'nameSourceField' => $nameSourceField,
295 'alias' => htmlspecialchars($page['alias'] ?: ''),
296 'prefix' => htmlspecialchars($prefix),
297 'suffix' => htmlspecialchars($suffix),
298 'locked' => is_array($lockInfo),
299 'overlayIcon' => $icon->getOverlayIcon() ? $icon->getOverlayIcon()->getIdentifier() : '',
300 'selectable' => true,
301 'expanded' => (bool)$expanded,
302 'checked' => false,
303 'backgroundColor' => htmlspecialchars($backgroundColor),
304 'stopPageTree' => $stopPageTree,
305 'class' => $this->resolvePageCssClassNames($page),
306 'readableRootline' => $depth === 0 && $this->showMountPathAboveMounts ? $this->getMountPointPath($pageId) : '',
307 'isMountPoint' => $depth === 0,
308 'mountPoint' => $entryPoint,
309 'workspaceId' => $page['t3ver_oid'] ?: $pageId,
310 ];
311 if (!$stopPageTree) {
312 foreach ($page['_children'] as $child) {
313 $items = array_merge($items, $this->pagesToFlatArray($child, $entryPoint, $depth + 1, ['backgroundColor' => $backgroundColor]));
314 }
315 }
316 return $items;
317 }
318
319 /**
320 * Fetches all entry points for the page tree that the user is allowed to see
321 *
322 * @return array
323 */
324 protected function getAllEntryPointPageTrees(): array
325 {
326 $backendUser = $this->getBackendUser();
327 $repository = GeneralUtility::makeInstance(PageTreeRepository::class, (int)$backendUser->workspace);
328 $pageRepository = GeneralUtility::makeInstance(PageRepository::class);
329
330 $entryPoints = (int)($backendUser->uc['pageTree_temporaryMountPoint'] ?? 0);
331 if ($entryPoints > 0) {
332 $entryPoints = [$entryPoints];
333 } else {
334 $entryPoints = array_map('intval', $backendUser->returnWebmounts());
335 $entryPoints = array_unique($entryPoints);
336 if (empty($entryPoints)) {
337 // use a virtual root
338 // the real mount points will be fetched in getNodes() then
339 // since those will be the "sub pages" of the virtual root
340 $entryPoints = [0];
341 }
342 }
343 if (empty($entryPoints)) {
344 return [];
345 }
346
347 foreach ($entryPoints as $k => &$entryPoint) {
348 if (in_array($entryPoint, $this->hiddenRecords, true)) {
349 unset($entryPoints[$k]);
350 continue;
351 }
352
353 if (!empty($this->backgroundColors) && is_array($this->backgroundColors)) {
354 $entryPointRootLine = $pageRepository->getRootLine($entryPoint);
355 foreach ($entryPointRootLine as $rootLineEntry) {
356 $parentUid = $rootLineEntry['uid'];
357 if ($this->backgroundColors[$parentUid] !== null && $this->backgroundColors[$entryPoint] === null) {
358 $this->backgroundColors[$entryPoint] = $this->backgroundColors[$parentUid];
359 }
360 }
361 }
362
363 $entryPoint = $repository->getTree($entryPoint, function ($page) use ($backendUser) {
364 // check each page if the user has permission to access it
365 return $backendUser->doesUserHaveAccess($page, Permission::PAGE_SHOW);
366 });
367 if (!is_array($entryPoint)) {
368 unset($entryPoints[$k]);
369 }
370 }
371
372 return $entryPoints;
373 }
374
375 /**
376 * Returns the first configured domain name for a page
377 *
378 * @param int $pageId
379 * @return string
380 */
381 protected function getDomainNameForPage(int $pageId): string
382 {
383 if (!is_array($this->domains)) {
384 $this->domains = [];
385 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
386 ->getQueryBuilderForTable('sys_domain');
387 $result = $queryBuilder
388 ->select('domainName', 'pid')
389 ->from('sys_domain')
390 ->orderBy('sorting')
391 ->execute()
392 ->fetchAll();
393 foreach ($result as $domain) {
394 $domainPid = (int)$domain['pid'];
395 if (!isset($this->domains[$domainPid])) {
396 $this->domains[$domainPid] = $domain['domainName'];
397 }
398 }
399 }
400 return $this->domains[$pageId] ?? '';
401 }
402
403 /**
404 * Returns the mount point path for a temporary mount or the given id
405 *
406 * @param int $uid
407 * @return string
408 */
409 protected function getMountPointPath(int $uid): string
410 {
411 if ($uid <= 0) {
412 return '';
413 }
414 $rootline = array_reverse(BackendUtility::BEgetRootLine($uid));
415 array_shift($rootline);
416 $path = [];
417 foreach ($rootline as $rootlineElement) {
418 $record = BackendUtility::getRecordWSOL('pages', $rootlineElement['uid'], 'title, nav_title', '', true, true);
419 $text = $record['title'];
420 if ($this->useNavTitle && trim($record['nav_title'] ?? '') !== '') {
421 $text = $record['nav_title'];
422 }
423 $path[] = htmlspecialchars($text);
424 }
425 return '/' . implode('/', $path);
426 }
427
428 /**
429 * Fetches possible css class names to be used when a record was modified in a workspace
430 *
431 * @param array $page Page record (workspace overlaid)
432 * @return string CSS class names to be applied
433 */
434 protected function resolvePageCssClassNames(array $page): string
435 {
436 $classes = [];
437
438 $workspaceId = (int)$this->getBackendUser()->workspace;
439 if ($workspaceId > 0 && ExtensionManagementUtility::isLoaded('workspaces')) {
440 if ($page['t3ver_oid'] > 0 && (int)$page['t3ver_wsid'] === $workspaceId) {
441 $classes[] = 'ver-element';
442 $classes[] = 'ver-versions';
443 } elseif (
444 $this->getWorkspaceService()->hasPageRecordVersions(
445 $workspaceId,
446 $page['t3ver_oid'] ?: $page['uid']
447 )
448 ) {
449 $classes[] = 'ver-versions';
450 }
451 }
452
453 return implode(' ', $classes);
454 }
455
456 /**
457 * @return WorkspaceService
458 */
459 protected function getWorkspaceService(): WorkspaceService
460 {
461 return GeneralUtility::makeInstance(WorkspaceService::class);
462 }
463
464 /**
465 * @return BackendUserAuthentication
466 */
467 protected function getBackendUser(): BackendUserAuthentication
468 {
469 return $GLOBALS['BE_USER'];
470 }
471 }