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