[BUGFIX] Correct tooltips for pages in the page tree
[Packages/TYPO3.CMS.git] / typo3 / sysext / backend / Classes / Tree / Repository / PageTreeRepository.php
1 <?php
2 declare(strict_types = 1);
3 namespace TYPO3\CMS\Backend\Tree\Repository;
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 TYPO3\CMS\Core\Database\ConnectionPool;
19 use TYPO3\CMS\Core\Database\Query\Restriction\BackendWorkspaceRestriction;
20 use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
21 use TYPO3\CMS\Core\DataHandling\PlainDataResolver;
22 use TYPO3\CMS\Core\Utility\GeneralUtility;
23 use TYPO3\CMS\Core\Versioning\VersionState;
24
25 /**
26 * Fetches ALL pages in the page tree, possibly overlaid with the workspace
27 * in a sorted way.
28 *
29 * This works agnostic of the Backend User, allows to be used in FE as well in the future.
30 *
31 * @internal this class is not public API yet, as it needs to be proven stable enough first.
32 */
33 class PageTreeRepository
34 {
35 /**
36 * Fields to be queried from the database
37 *
38 * @var string[]
39 */
40 protected $fields = [
41 'uid',
42 'pid',
43 'sorting',
44 'starttime',
45 'endtime',
46 'hidden',
47 'fe_group',
48 'title',
49 'nav_title',
50 'nav_hide',
51 'alias',
52 'php_tree_stop',
53 'doktype',
54 'is_siteroot',
55 'module',
56 'extendToSubpages',
57 'content_from_pid',
58 't3ver_oid',
59 't3ver_id',
60 't3ver_wsid',
61 't3ver_label',
62 't3ver_state',
63 't3ver_stage',
64 't3ver_tstamp',
65 't3ver_move_id',
66 'perms_userid',
67 'perms_user',
68 'perms_groupid',
69 'perms_group',
70 'perms_everybody',
71 'mount_pid',
72 'shortcut',
73 'shortcut_mode',
74 'mount_pid_ol',
75 'url',
76 ];
77
78 /**
79 * The workspace ID to operate on
80 *
81 * @var int
82 */
83 protected $currentWorkspace = 0;
84
85 /**
86 * Full page tree when selected without permissions applied.
87 *
88 * @var array
89 */
90 protected $fullPageTree = [];
91
92 /**
93 * @param int $workspaceId the workspace ID to be checked for.
94 * @param array $additionalFieldsToQuery an array with more fields that should be accessed.
95 */
96 public function __construct(int $workspaceId = 0, array $additionalFieldsToQuery = [])
97 {
98 $this->currentWorkspace = $workspaceId;
99 if (!empty($additionalFieldsToQuery)) {
100 $this->fields = array_merge($this->fields, $additionalFieldsToQuery);
101 }
102 }
103
104 /**
105 * Main entry point for this repository, to fetch the tree data for a page.
106 * Basically the page record, plus all child pages and their child pages recursively, stored within "_children" item.
107 *
108 * @param int $entryPoint the page ID to fetch the tree for
109 * @param callable $callback a callback to be used to check for permissions and filter out pages not to be included.
110 * @return array
111 */
112 public function getTree(int $entryPoint, callable $callback = null): array
113 {
114 $this->fetchAllPages();
115 if ($entryPoint === 0) {
116 $tree = $this->fullPageTree;
117 } else {
118 $tree = $this->findInPageTree($entryPoint, $this->fullPageTree);
119 }
120 if (!empty($tree) && $callback !== null) {
121 $this->applyCallbackToChildren($tree, $callback);
122 }
123 return $tree;
124 }
125
126 /**
127 * Removes items from a tree based on a callback, usually used for permission checks
128 *
129 * @param array $tree
130 * @param callable $callback
131 */
132 protected function applyCallbackToChildren(array &$tree, callable $callback)
133 {
134 if (!isset($tree['_children'])) {
135 return;
136 }
137 foreach ($tree['_children'] as $k => $childPage) {
138 if (!call_user_func_array($callback, [$childPage])) {
139 unset($tree['_children'][$k]);
140 continue;
141 }
142 $this->applyCallbackToChildren($childPage, $callback);
143 }
144 }
145
146 /**
147 * Fetch all non-deleted pages, regardless of permissions. That's why it's internal.
148 *
149 * @return array the full page tree of the whole installation
150 */
151 protected function fetchAllPages(): array
152 {
153 if (!empty($this->fullPageTree)) {
154 return $this->fullPageTree;
155 }
156 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
157 ->getQueryBuilderForTable('pages');
158 $queryBuilder->getRestrictions()
159 ->removeAll()
160 ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
161 ->add(GeneralUtility::makeInstance(
162 BackendWorkspaceRestriction::class,
163 $this->currentWorkspace,
164 // set this flag to "true" when inside a workspace
165 $this->currentWorkspace !== 0
166 ));
167
168 $pageRecords = $queryBuilder
169 ->select(...$this->fields)
170 ->from('pages')
171 ->where(
172 // Only show records in default language
173 $queryBuilder->expr()->eq('sys_language_uid', $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT))
174 )
175 ->execute()
176 ->fetchAll();
177
178 $livePagePids = [];
179 $movePlaceholderData = [];
180 // This is necessary to resolve all IDs in a workspace
181 if ($this->currentWorkspace !== 0 && !empty($pageRecords)) {
182 $livePageIds = [];
183 foreach ($pageRecords as $pageRecord) {
184 // BackendWorkspaceRestriction includes drafts from ALL workspaces, we need to ensure
185 // that only the live records and the drafts from the current workspace are used
186 if (!in_array((int)$pageRecord['t3ver_wsid'], [0, $this->currentWorkspace], true)) {
187 continue;
188 }
189 $livePageIds[] = (int)$pageRecord['uid'];
190 $livePagePids[(int)$pageRecord['uid']] = (int)$pageRecord['pid'];
191 if ((int)$pageRecord['t3ver_state'] === VersionState::MOVE_PLACEHOLDER) {
192 $movePlaceholderData[$pageRecord['t3ver_move_id']] = [
193 'pid' => (int)$pageRecord['pid'],
194 'sorting' => (int)$pageRecord['sorting']
195 ];
196 }
197 }
198 // Resolve placeholders of workspace versions
199 $resolver = GeneralUtility::makeInstance(
200 PlainDataResolver::class,
201 'pages',
202 $livePageIds
203 );
204 $resolver->setWorkspaceId($this->currentWorkspace);
205 $resolver->setKeepDeletePlaceholder(false);
206 $resolver->setKeepMovePlaceholder(false);
207 $resolver->setKeepLiveIds(false);
208 $recordIds = $resolver->get();
209
210 $queryBuilder->getRestrictions()->removeAll();
211 $pageRecords = $queryBuilder
212 ->select(...$this->fields)
213 ->from('pages')
214 ->where(
215 $queryBuilder->expr()->in('uid', $recordIds)
216 )
217 ->execute()
218 ->fetchAll();
219 }
220
221 // Now set up sorting, nesting (tree-structure) for all pages based on pid+sorting fields
222 $groupedAndSortedPagesByPid = [];
223 foreach ($pageRecords as $pageRecord) {
224 $parentPageId = (int)$pageRecord['pid'];
225 // In case this is a record from a workspace
226 // The uid+pid of the live-version record is fetched
227 // This is done in order to avoid fetching records again (e.g. via BackendUtility::workspaceOL()
228 if ($parentPageId === -1) {
229 // When a move pointer is found, the pid+sorting of the MOVE_PLACEHOLDER should be used (this is the
230 // workspace record holding this information), also the t3ver_state is set to the MOVE_PLACEHOLDER
231 // because the record is then added
232 if ((int)$pageRecord['t3ver_state'] === VersionState::MOVE_POINTER && !empty($movePlaceholderData[$pageRecord['t3ver_oid']])) {
233 $parentPageId = (int)$movePlaceholderData[$pageRecord['t3ver_oid']]['pid'];
234 $pageRecord['sorting'] = (int)$movePlaceholderData[$pageRecord['t3ver_oid']]['sorting'];
235 $pageRecord['t3ver_state'] = VersionState::MOVE_PLACEHOLDER;
236 } else {
237 // Just a record in a workspace (not moved etc)
238 $parentPageId = (int)$livePagePids[$pageRecord['t3ver_oid']];
239 }
240 // this is necessary so the links to the modules are still pointing to the live IDs
241 $pageRecord['uid'] = (int)$pageRecord['t3ver_oid'];
242 $pageRecord['pid'] = $parentPageId;
243 }
244
245 $sorting = (int)$pageRecord['sorting'];
246 while (isset($groupedAndSortedPagesByPid[$parentPageId][$sorting])) {
247 $sorting++;
248 }
249 $groupedAndSortedPagesByPid[$parentPageId][$sorting] = $pageRecord;
250 }
251
252 $this->fullPageTree = [
253 'uid' => 0,
254 'title' => $GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'] ?: 'TYPO3'
255 ];
256 $this->addChildrenToPage($this->fullPageTree, $groupedAndSortedPagesByPid);
257 return $this->fullPageTree;
258 }
259
260 /**
261 * Adds the property "_children" to a page record with the child pages
262 *
263 * @param array $page
264 * @param array[] $groupedAndSortedPagesByPid
265 */
266 protected function addChildrenToPage(array &$page, array &$groupedAndSortedPagesByPid)
267 {
268 $page['_children'] = $groupedAndSortedPagesByPid[(int)$page['uid']] ?? [];
269 ksort($page['_children']);
270 foreach ($page['_children'] as &$child) {
271 $this->addChildrenToPage($child, $groupedAndSortedPagesByPid);
272 }
273 }
274
275 /**
276 * Looking for a page by traversing the tree
277 *
278 * @param int $pageId the page ID to search for
279 * @param array $pages the page tree to look for the page
280 * @return array Array of the tree data, empty array if nothing was found
281 */
282 protected function findInPageTree(int $pageId, array $pages): array
283 {
284 foreach ($pages['_children'] as $childPage) {
285 if ((int)$childPage['uid'] === $pageId) {
286 return $childPage;
287 }
288 $result = $this->findInPageTree($pageId, $childPage);
289 if (!empty($result)) {
290 return $result;
291 }
292 }
293 return [];
294 }
295 }