[TASK] Speed up page tree loading 87/54887/31
authorTymoteusz Motylewski <t.motylewski@gmail.com>
Fri, 8 Dec 2017 15:05:17 +0000 (16:05 +0100)
committerGeorg Ringer <georg.ringer@gmail.com>
Sat, 9 Dec 2017 18:24:50 +0000 (19:24 +0100)
Instead of doing manual queries for each tree level, and checking
for children on each tree level, the page tree now fetches all pages
the user has access to, and checks if this works out.

Resolves: #83233
Releases: master
Change-Id: I45a6b834ef1fe71e5748dfc8de9bcf6dad8172c9
Reviewed-on: https://review.typo3.org/54887
Reviewed-by: Susanne Moog <susanne.moog@typo3.org>
Tested-by: Susanne Moog <susanne.moog@typo3.org>
Tested-by: TYPO3com <no-reply@typo3.com>
Reviewed-by: Georg Ringer <georg.ringer@gmail.com>
Tested-by: Georg Ringer <georg.ringer@gmail.com>
composer.json
composer.lock
typo3/sysext/backend/Classes/Controller/Page/TreeController.php
typo3/sysext/backend/Classes/Tree/Repository/PageTreeRepository.php [new file with mode: 0644]
typo3/sysext/backend/Resources/Public/JavaScript/PageTree/PageTree.js

index b091e04..83eac6c 100644 (file)
@@ -53,7 +53,7 @@
                "symfony/polyfill-intl-icu": "^1.6"
        },
        "require-dev": {
-               "typo3/testing-framework": "2.0.0",
+               "typo3/testing-framework": "2.0.1",
                "codeception/codeception": "^2.3",
                "enm1989/chromedriver": "~2.30",
                "typo3/cms-styleguide": "~9.0.0",
index 89d0a01..608a3fc 100644 (file)
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
         "This file is @generated automatically"
     ],
-    "content-hash": "4c0c03e1d8cb80b05f541e6691629430",
+    "content-hash": "9ccdad435278ac728c4fda07a6e55d58",
     "packages": [
         {
             "name": "cogpowered/finediff",
         },
         {
             "name": "typo3/testing-framework",
-            "version": "2.0.0",
+            "version": "2.0.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/TYPO3/testing-framework.git",
-                "reference": "cc024cfd812e501430daba493c00d6cdcfb97c83"
+                "reference": "c99bee70611359bc58a5c3d8a9aab7dd86bd3cb0"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/TYPO3/testing-framework/zipball/cc024cfd812e501430daba493c00d6cdcfb97c83",
-                "reference": "cc024cfd812e501430daba493c00d6cdcfb97c83",
+                "url": "https://api.github.com/repos/TYPO3/testing-framework/zipball/c99bee70611359bc58a5c3d8a9aab7dd86bd3cb0",
+                "reference": "c99bee70611359bc58a5c3d8a9aab7dd86bd3cb0",
                 "shasum": ""
             },
             "require": {
                 "tests",
                 "typo3"
             ],
-            "time": "2017-12-03T14:49:01+00:00"
+            "time": "2017-12-06T15:03:20+00:00"
         },
         {
             "name": "webmozart/assert",
index 461f024..b9e14ca 100644 (file)
@@ -18,18 +18,95 @@ namespace TYPO3\CMS\Backend\Controller\Page;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
 use TYPO3\CMS\Backend\Controller\UserSettingsController;
-use TYPO3\CMS\Backend\Tree\Pagetree\Commands;
-use TYPO3\CMS\Backend\Tree\Pagetree\ExtdirectTreeDataProvider;
+use TYPO3\CMS\Backend\Tree\Repository\PageTreeRepository;
+use TYPO3\CMS\Backend\Utility\BackendUtility;
 use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
+use TYPO3\CMS\Core\Database\ConnectionPool;
 use TYPO3\CMS\Core\Http\JsonResponse;
+use TYPO3\CMS\Core\Imaging\Icon;
+use TYPO3\CMS\Core\Imaging\IconFactory;
 use TYPO3\CMS\Core\Type\Bitmask\JsConfirmation;
+use TYPO3\CMS\Core\Type\Bitmask\Permission;
+use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Workspaces\Service\WorkspaceService;
 
 /**
  * Controller providing data to the page tree
  */
 class TreeController
 {
+    /**
+     * Option to use the nav_title field for outputting in the tree items, set via userTS.
+     *
+     * @var bool
+     */
+    protected $useNavTitle = false;
+
+    /**
+     * Option to prefix the page ID when outputting the tree items, set via userTS.
+     *
+     * @var bool
+     */
+    protected $addIdAsPrefix = false;
+
+    /**
+     * Option to prefix the domain name of sys_domains when outputting the tree items, set via userTS.
+     *
+     * @var bool
+     */
+    protected $addDomainName = false;
+
+    /**
+     * Option to add the rootline path above each mount point, set via userTS.
+     *
+     * @var bool
+     */
+    protected $showMountPathAboveMounts = false;
+
+    /**
+     * An array of background colors for a branch in the tree, set via userTS.
+     *
+     * @var array
+     */
+    protected $backgroundColors = [];
+
+    /**
+     * A list of pages not to be shown.
+     *
+     * @var array
+     */
+    protected $hiddenRecords = [];
+
+    /**
+     * Contains the state of all items that are expanded.
+     *
+     * @var array
+     */
+    protected $expandedState = [];
+
+    /**
+     * Associative array containing all pageIds as key, and domain names as values.
+     *
+     * @var array|null
+     */
+    protected $domains = null;
+
+    /**
+     * Instance of the icon factory, to be used for generating the items.
+     *
+     * @var IconFactory
+     */
+    protected $iconFactory;
+
+    /**
+     * Constructor to set up common objects needed in various places.
+     */
+    public function __construct()
+    {
+        $this->iconFactory = GeneralUtility::makeInstance(IconFactory::class);
+        $this->useNavTitle = (bool)$this->getBackendUser()->getTSConfigVal('options.pageTree.showNavTitle');
+    }
 
     /**
      * Returns page tree configuration in JSON
@@ -42,7 +119,7 @@ class TreeController
             'allowRecursiveDelete' => !empty($this->getBackendUser()->uc['recursiveDelete']),
             'doktypes' => $this->getDokTypes(),
             'displayDeleteConfirmation' => $this->getBackendUser()->jsConfirmation(JsConfirmation::DELETE),
-            'temporaryMountPoint' => Commands::getMountPointPath(),
+            'temporaryMountPoint' => $this->getMountPointPath((int)($this->getBackendUser()->uc['pageTree_temporaryMountPoint'] ?? 0)),
         ];
 
         return GeneralUtility::makeInstance(JsonResponse::class, $configuration);
@@ -67,7 +144,7 @@ class TreeController
         }
         $doktypes = GeneralUtility::intExplode(',', $this->getBackendUser()->getTSConfigVal('options.pageTree.doktypesToShowInNewPageDragArea'));
         $output = [];
-        $allowedDoktypes = GeneralUtility::intExplode(',', $GLOBALS['BE_USER']->groupData['pagetypes_select'], true);
+        $allowedDoktypes = GeneralUtility::intExplode(',', $this->getBackendUser()->groupData['pagetypes_select'], true);
         $isAdmin = $this->getBackendUser()->isAdmin();
         // Early return if backend user may not create any doktype
         if (!$isAdmin && empty($allowedDoktypes)) {
@@ -92,71 +169,35 @@ class TreeController
      * Returns JSON representing page tree
      *
      * @param ServerRequestInterface $request
-     * @throws \RuntimeException
      * @return ResponseInterface
      */
     public function fetchDataAction(ServerRequestInterface $request): ResponseInterface
     {
-        $dataProvider = GeneralUtility::makeInstance(ExtdirectTreeDataProvider::class);
-        $node = new \stdClass();
-        $node->id = 'root';
-        if (!empty($request->getQueryParams()['pid'])) {
-            $node->id = $request->getQueryParams()['pid'];
+        $this->hiddenRecords = GeneralUtility::intExplode(',', $this->getBackendUser()->getTSConfigVal('options.hideRecords.pages'), true);
+        $this->backgroundColors = $this->getBackendUser()->getTSConfigProp('options.pageTree.backgroundColor');
+        $this->addIdAsPrefix = (bool)$this->getBackendUser()->getTSConfigVal('options.pageTree.showPageIdWithTitle');
+        $this->addDomainName = (bool)$this->getBackendUser()->getTSConfigVal('options.pageTree.showDomainNameWithTitle');
+        $this->showMountPathAboveMounts = (bool)$this->getBackendUser()->getTSConfigVal('options.pageTree.showPathAboveMounts');
+        $userSettingsController = GeneralUtility::makeInstance(UserSettingsController::class);
+        $this->expandedState = $userSettingsController->process('get', 'BackendComponents.States.Pagetree');
+        if (is_object($this->expandedState->stateHash)) {
+            $this->expandedState = (array)$this->expandedState->stateHash;
+        } else {
+            $this->expandedState = $this->expandedState['stateHash'] ?: [];
         }
-        $nodeArray = $dataProvider->getNextTreeLevel($node->id, $node);
 
-        //@todo refactor the PHP data provider side. Now we're using the old pagetree code and flatten the array afterwards
-        $items = $this->nodeToFlatArray($nodeArray);
-
-        return GeneralUtility::makeInstance(JsonResponse::class, $items);
-    }
-
-    /**
-     * Converts nested tree structure produced by ExtdirectTreeDataProvider to a flat, one level array
-     *
-     * @param array $nodeArray
-     * @param int $depth
-     * @param array $inheritedData
-     * @return array
-     */
-    protected function nodeToFlatArray(array $nodeArray, int $depth = 0, array $inheritedData = []): array
-    {
-        $userSettingsController = GeneralUtility::makeInstance(UserSettingsController::class);
-        $state = $userSettingsController->process('get', 'BackendComponents.States.Pagetree');
+        // Fetching a part of a pagetree
+        if (!empty($request->getQueryParams()['pid'])) {
+            $entryPoints = [(int)$request->getQueryParams()['pid']];
+        } else {
+            $entryPoints = $this->getAllEntryPointPageTrees();
+        }
         $items = [];
-        foreach ($nodeArray as $key => $node) {
-            $hexId = dechex($node['nodeData']['id']);
-            $expanded = $node['nodeData']['expanded'] || (isset($state['stateHash'][$hexId]) && $state['stateHash'][$hexId]);
-            $backgroundColor = !empty($node['nodeData']['backgroundColor']) ? $node['nodeData']['backgroundColor'] : ($inheritedData['backgroundColor'] ?? '');
-            $items[] = [
-                'identifier' => $node['nodeData']['id'],
-                'depth' => $depth,
-                'hasChildren' => !empty($node['children']),
-                'icon' => $node['icon'],
-                'name' => $node['editableText'],
-                'tip' => $node['qtip'],
-                'nameSourceField' => $node['t3TextSourceField'],
-                'alias' => $node['alias'],
-                'prefix' => $node['prefix'],
-                'suffix' => $node['suffix'],
-                'overlayIcon' => $node['overlayIcon'],
-                'selectable' => true,
-                'expanded' => (bool)$expanded,
-                'checked' => false,
-                'backgroundColor' => htmlspecialchars($backgroundColor),
-                'stopPageTree' => $node['nodeData']['stopPageTree'],
-                //used to mark versioned records, see $row['_CSSCLASS'], e.g. ver-element
-                'class' => (string)$node['cls'],
-                'readableRootline' => $node['nodeData']['readableRootline'],
-                'isMountPoint' => $node['nodeData']['isMountPoint'],
-                'mountPoint' => $node['nodeData']['mountPoint'],
-                'workspaceId' => $node['nodeData']['workspaceId'],
-            ];
-            if (!empty($node['children'])) {
-                $items = array_merge($items, $this->nodeToFlatArray($node['children'], $depth + 1, ['backgroundColor' => $backgroundColor]));
-            }
+        foreach ($entryPoints as $page) {
+            $items = array_merge($items, $this->pagesToFlatArray($page, (int)$page['uid']));
         }
-        return $items;
+
+        return GeneralUtility::makeInstance(JsonResponse::class, $items);
     }
 
     /**
@@ -177,17 +218,241 @@ class TreeController
         $pid = (int)$request->getParsedBody()['pid'];
 
         $this->getBackendUser()->uc['pageTree_temporaryMountPoint'] = $pid;
-        $this->getBackendUser()->writeUC(static::getBackendUser()->uc);
+        $this->getBackendUser()->writeUC();
         $response = [
-            'mountPointPath' => Commands::getMountPointPath(),
+            'mountPointPath' => $this->getMountPointPath($pid)
         ];
         return GeneralUtility::makeInstance(JsonResponse::class, $response);
     }
 
     /**
+     * Converts nested tree structure produced by PageTreeRepository to a flat, one level array
+     * and also adds visual representation information to the data.
+     *
+     * @param array $page
+     * @param int $entryPoint
+     * @param int $depth
+     * @param array $inheritedData
+     * @return array
+     */
+    protected function pagesToFlatArray(array $page, int $entryPoint, int $depth = 0, array $inheritedData = []): array
+    {
+        $pageId = (int)$page['uid'];
+        if (in_array($pageId, $this->hiddenRecords, true)) {
+            return [];
+        }
+
+        $stopPageTree = $page['php_tree_stop'] && $depth > 0;
+        $identifier = $entryPoint . '_' . $pageId;
+        $expanded = $page['expanded'] || (isset($this->expandedState[$identifier]) && $this->expandedState[$identifier]);
+        $backgroundColor = $this->backgroundColors[$pageId] ?: ($inheritedData['backgroundColor'] ?? '');
+
+        $suffix = '';
+        $prefix = '';
+        $nameSourceField = 'title';
+        $visibleText = $page['title'];
+        $tooltip = BackendUtility::titleAttribForPages($page, '', false);
+        if ($pageId !== 0) {
+            $icon = $this->iconFactory->getIconForRecord('pages', $page, Icon::SIZE_SMALL);
+        } else {
+            $icon = $this->iconFactory->getIcon('apps-pagetree-root', Icon::SIZE_SMALL);
+        }
+
+        if ($this->useNavTitle && trim($page['nav_title'] ?? '') !== '') {
+            $nameSourceField = 'nav_title';
+            $visibleText = $page['nav_title'];
+        }
+        if (trim($visibleText) === '') {
+            $visibleText = '[' . $GLOBALS['LANG']->sL('LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:labels.no_title') . ']';
+        }
+        $visibleText = GeneralUtility::fixed_lgd_cs($visibleText, (int)$this->getBackendUser()->uc['titleLen'] ?: 40);
+
+        if ($this->addDomainName) {
+            $domain = $this->getDomainNameForPage($pageId);
+            $suffix = $domain !== '' ? ' [' . $domain . ']' : '';
+        }
+
+        $lockInfo = BackendUtility::isRecordLocked('pages', $pageId);
+        if (is_array($lockInfo)) {
+            $tooltip .= ' - ' . $lockInfo['msg'];
+            $prefix = '<span class="typo3-pagetree-status">' . $this->iconFactory->getIcon('warning-in-use', Icon::SIZE_SMALL)->render() . '</span>';
+        }
+        if ($this->addIdAsPrefix) {
+            $prefix .= htmlspecialchars('[' . $pageId . '] ');
+        }
+
+        $items = [];
+        $items[] = [
+            // Used to track if the tree item is collapsed or not
+            'stateIdentifier' => $identifier,
+            'identifier' => $pageId,
+            'depth' => $depth,
+            'tip' => htmlspecialchars($tooltip),
+            'hasChildren' => !empty($page['_children']),
+            'icon' => $icon->getIdentifier(),
+            'name' => htmlspecialchars($visibleText),
+            'nameSourceField' => $nameSourceField,
+            'alias' => htmlspecialchars($page['alias'] ?: ''),
+            'prefix' => htmlspecialchars($prefix),
+            'suffix' => htmlspecialchars($suffix),
+            'overlayIcon' => $icon->getOverlayIcon() ? $icon->getOverlayIcon()->getIdentifier() : '',
+            'selectable' => true,
+            'expanded' => (bool)$expanded,
+            'checked' => false,
+            'backgroundColor' => htmlspecialchars($backgroundColor),
+            'stopPageTree' => $stopPageTree,
+            'class' => $this->resolvePageCssClassNames($page),
+            'readableRootline' => ($depth === 0 && $this->showMountPathAboveMounts ? $this->getMountPointPath($pageId) : ''),
+            'isMountPoint' => $depth === 0,
+            'mountPoint' => $entryPoint,
+            'workspaceId' => $page['t3ver_oid'] ?: $pageId,
+        ];
+        if (!$stopPageTree) {
+            foreach ($page['_children'] as $child) {
+                $items = array_merge($items, $this->pagesToFlatArray($child, $entryPoint, $depth + 1, ['backgroundColor' => $backgroundColor]));
+            }
+        }
+        return $items;
+    }
+
+    /**
+     * Fetches all entry points for the page tree that the user is allowed to see
+     *
+     * @return array
+     */
+    protected function getAllEntryPointPageTrees(): array
+    {
+        $backendUser = $this->getBackendUser();
+        $repository = GeneralUtility::makeInstance(PageTreeRepository::class, (int)$backendUser->workspace);
+        $entryPoints = (int)($backendUser->uc['pageTree_temporaryMountPoint'] ?? 0);
+        if ($entryPoints > 0) {
+            $entryPoints = [$entryPoints];
+        } else {
+            $entryPoints = array_map('intval', $backendUser->returnWebmounts());
+            $entryPoints = array_unique($entryPoints);
+            if (empty($entryPoints)) {
+                // use a virtual root
+                // the real mount points will be fetched in getNodes() then
+                // since those will be the "sub pages" of the virtual root
+                $entryPoints = [0];
+            }
+        }
+        if (empty($entryPoints)) {
+            return [];
+        }
+        foreach ($entryPoints as $k => &$entryPoint) {
+            if (in_array($entryPoint, $this->hiddenRecords, true)) {
+                unset($entryPoints[$k]);
+                continue;
+            }
+            $entryPoint = $repository->getTree($entryPoint, function ($page) use ($backendUser) {
+                // check each page if the user has permission to access it
+                return $backendUser->doesUserHaveAccess($page, Permission::PAGE_SHOW);
+            });
+            if (!is_array($entryPoint)) {
+                unset($entryPoints[$k]);
+            }
+        }
+
+        return $entryPoints;
+    }
+
+    /**
+     * Returns the first configured domain name for a page
+     *
+     * @param int $pageId
+     * @return string
+     */
+    protected function getDomainNameForPage(int $pageId): string
+    {
+        if (!is_array($this->domains)) {
+            $this->domains = [];
+            $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
+                ->getQueryBuilderForTable('sys_domain');
+            $result = $queryBuilder
+                ->select('domainName', 'pid')
+                ->from('sys_domain')
+                ->where(
+                    $queryBuilder->expr()->neq('redirectTo', $queryBuilder->createNamedParameter('""'))
+                )
+                ->orderBy('sorting')
+                ->execute()
+                ->fetchAll();
+            foreach ($result as $domain) {
+                $domainPid = (int)$domain['pid'];
+                if (!isset($this->domains[$domainPid])) {
+                    $this->domains[$domainPid] = $domain['domainName'];
+                }
+            }
+        }
+        return $this->domains[$pageId] ?? '';
+    }
+
+    /**
+     * Returns the mount point path for a temporary mount or the given id
+     *
+     * @param int $uid
+     * @return string
+     */
+    protected function getMountPointPath(int $uid): string
+    {
+        if ($uid <= 0) {
+            return '';
+        }
+        $rootline = array_reverse(BackendUtility::BEgetRootLine($uid));
+        array_shift($rootline);
+        $path = [];
+        foreach ($rootline as $rootlineElement) {
+            $record = BackendUtility::getRecordWSOL('pages', $rootlineElement['uid'], 'title, nav_title', '', true, true);
+            $text = $record['title'];
+            if ($this->useNavTitle && trim($record['nav_title'] ?? '') !== '') {
+                $text = $record['nav_title'];
+            }
+            $path[] = htmlspecialchars($text);
+        }
+        return '/' . implode('/', $path);
+    }
+
+    /**
+     * Fetches possible css class names to be used when a record was modified in a workspace
+     *
+     * @param array $page Page record (workspace overlaid)
+     * @return string CSS class names to be applied
+     */
+    protected function resolvePageCssClassNames(array $page): string
+    {
+        $classes = [];
+
+        $workspaceId = (int)$this->getBackendUser()->workspace;
+        if ($workspaceId > 0 && ExtensionManagementUtility::isLoaded('workspaces')) {
+            if ($page['t3ver_oid'] > 0 && (int)$page['t3ver_wsid'] === $workspaceId) {
+                $classes[] = 'ver-element';
+                $classes[] = 'ver-versions';
+            } elseif (
+                $this->getWorkspaceService()->hasPageRecordVersions(
+                    $workspaceId,
+                    $page['t3ver_oid'] ?: $page['uid']
+                )
+            ) {
+                $classes[] = 'ver-versions';
+            }
+        }
+
+        return implode(' ', $classes);
+    }
+
+    /**
+     * @return WorkspaceService
+     */
+    protected function getWorkspaceService(): WorkspaceService
+    {
+        return GeneralUtility::makeInstance(WorkspaceService::class);
+    }
+
+    /**
      * @return BackendUserAuthentication
      */
-    protected function getBackendUser()
+    protected function getBackendUser(): BackendUserAuthentication
     {
         return $GLOBALS['BE_USER'];
     }
diff --git a/typo3/sysext/backend/Classes/Tree/Repository/PageTreeRepository.php b/typo3/sysext/backend/Classes/Tree/Repository/PageTreeRepository.php
new file mode 100644 (file)
index 0000000..39fc4a1
--- /dev/null
@@ -0,0 +1,283 @@
+<?php
+declare(strict_types=1);
+namespace TYPO3\CMS\Backend\Tree\Repository;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use TYPO3\CMS\Core\Database\ConnectionPool;
+use TYPO3\CMS\Core\Database\Query\Restriction\BackendWorkspaceRestriction;
+use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
+use TYPO3\CMS\Core\DataHandling\PlainDataResolver;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Core\Versioning\VersionState;
+
+/**
+ * Fetches ALL pages in the page tree, possibly overlaid with the workspace
+ * in a sorted way.
+ *
+ * This works agnostic of the Backend User, allows to be used in FE as well in the future.
+ *
+ * @internal this class is not public API yet, as it needs to be proven stable enough first.
+ */
+class PageTreeRepository
+{
+    /**
+     * Fields to be queried from the database
+     *
+     * @var string[]
+     */
+    protected $fields = [
+        'uid',
+        'pid',
+        'sorting',
+        'starttime',
+        'endtime',
+        'hidden',
+        'fe_group',
+        'title',
+        'nav_title',
+        'nav_hide',
+        'alias',
+        'php_tree_stop',
+        'doktype',
+        'is_siteroot',
+        'module',
+        't3ver_oid',
+        't3ver_id',
+        't3ver_wsid',
+        't3ver_label',
+        't3ver_state',
+        't3ver_stage',
+        't3ver_tstamp',
+        't3ver_move_id',
+    ];
+
+    /**
+     * The workspace ID to operate on
+     *
+     * @var int
+     */
+    protected $currentWorkspace = 0;
+
+    /**
+     * Full page tree when selected without permissions applied.
+     *
+     * @var array
+     */
+    protected $fullPageTree = [];
+
+    /**
+     * @param int $workspaceId the workspace ID to be checked for.
+     * @param array $additionalFieldsToQuery an array with more fields that should be accessed.
+     */
+    public function __construct(int $workspaceId = 0, array $additionalFieldsToQuery = [])
+    {
+        $this->currentWorkspace = $workspaceId;
+        if (!empty($additionalFieldsToQuery)) {
+            $this->fields = array_merge($this->fields, $additionalFieldsToQuery);
+        }
+    }
+
+    /**
+     * Main entry point for this repository, to fetch the tree data for a page.
+     * Basically the page record, plus all child pages and their child pages recursively, stored within "_children" item.
+     *
+     * @param int $entryPoint the page ID to fetch the tree for
+     * @param callable $callback a callback to be used to check for permissions and filter out pages not to be included.
+     * @return array
+     */
+    public function getTree(int $entryPoint, callable $callback = null): array
+    {
+        $this->fetchAllPages();
+        if ($entryPoint === 0) {
+            $tree = $this->fullPageTree;
+        } else {
+            $tree = $this->findInPageTree($entryPoint, $this->fullPageTree);
+        }
+        if (!empty($tree) && $callback !== null) {
+            $this->applyCallbackToChildren($tree, $callback);
+        }
+        return $tree;
+    }
+
+    /**
+     * Removes items from a tree based on a callback, usually used for permission checks
+     *
+     * @param array $tree
+     * @param callable $callback
+     */
+    protected function applyCallbackToChildren(array &$tree, callable $callback)
+    {
+        if (!isset($tree['_children'])) {
+            return;
+        }
+        foreach ($tree['_children'] as $k => $childPage) {
+            if (!call_user_func_array($callback, [$childPage])) {
+                unset($tree['_children'][$k]);
+                continue;
+            }
+            $this->applyCallbackToChildren($childPage, $callback);
+        }
+    }
+
+    /**
+     * Fetch all non-deleted pages, regardless of permissions. That's why it's internal.
+     *
+     * @return array the full page tree of the whole installation
+     */
+    protected function fetchAllPages(): array
+    {
+        if (!empty($this->fullPageTree)) {
+            return $this->fullPageTree;
+        }
+        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
+            ->getQueryBuilderForTable('pages');
+        $queryBuilder->getRestrictions()
+            ->removeAll()
+            ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
+            ->add(GeneralUtility::makeInstance(
+                BackendWorkspaceRestriction::class,
+                $this->currentWorkspace,
+                // set this flag to "true" when inside a workspace
+                $this->currentWorkspace !== 0
+            ));
+
+        $pageRecords = $queryBuilder
+            ->select(...$this->fields)
+            ->from('pages')
+            ->where(
+                // Only show records in default language
+                $queryBuilder->expr()->eq('sys_language_uid', $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT))
+            )
+            ->execute()
+            ->fetchAll();
+
+        $livePagePids = [];
+        $movePlaceholderData = [];
+        // This is necessary to resolve all IDs in a workspace
+        if ($this->currentWorkspace !== 0 && !empty($pageRecords)) {
+            $livePageIds = [];
+            foreach ($pageRecords as $pageRecord) {
+                // BackendWorkspaceRestriction includes drafts from ALL workspaces, we need to ensure
+                // that only the live records and the drafts from the current workspace are used
+                if (!in_array((int)$pageRecord['t3ver_wsid'], [0, $this->currentWorkspace], true)) {
+                    continue;
+                }
+                $livePageIds[] = (int)$pageRecord['uid'];
+                $livePagePids[(int)$pageRecord['uid']] = (int)$pageRecord['pid'];
+                if ((int)$pageRecord['t3ver_state'] === VersionState::MOVE_PLACEHOLDER) {
+                    $movePlaceholderData[$pageRecord['t3ver_move_id']] = [
+                        'pid' => (int)$pageRecord['pid'],
+                        'sorting' => (int)$pageRecord['sorting']
+                    ];
+                }
+            }
+            // Resolve placeholders of workspace versions
+            $resolver = GeneralUtility::makeInstance(
+                PlainDataResolver::class,
+                'pages',
+                $livePageIds
+            );
+            $resolver->setWorkspaceId($this->currentWorkspace);
+            $resolver->setKeepDeletePlaceholder(false);
+            $resolver->setKeepMovePlaceholder(false);
+            $resolver->setKeepLiveIds(false);
+            $recordIds = $resolver->get();
+
+            $queryBuilder->getRestrictions()->removeAll();
+            $pageRecords = $queryBuilder
+                ->select(...$this->fields)
+                ->from('pages')
+                ->where(
+                    $queryBuilder->expr()->in('uid', $recordIds)
+                )
+                ->execute()
+                ->fetchAll();
+        }
+
+        // Now set up sorting, nesting (tree-structure) for all pages based on pid+sorting fields
+        $groupedAndSortedPagesByPid = [];
+        foreach ($pageRecords as $pageRecord) {
+            $parentPageId = (int)$pageRecord['pid'];
+            // In case this is a record from a workspace
+            // The uid+pid of the live-version record is fetched
+            // This is done in order to avoid fetching records again (e.g. via BackendUtility::workspaceOL()
+            if ($parentPageId === -1) {
+                // When a move pointer is found, the pid+sorting of the MOVE_PLACEHOLDER should be used (this is the
+                // workspace record holding this information), also the t3ver_state is set to the MOVE_PLACEHOLDER
+                // because the record is then added
+                if ((int)$pageRecord['t3ver_state'] === VersionState::MOVE_POINTER && !empty($movePlaceholderData[$pageRecord['t3ver_oid']])) {
+                    $parentPageId = (int)$movePlaceholderData[$pageRecord['t3ver_oid']]['pid'];
+                    $pageRecord['sorting'] = (int)$movePlaceholderData[$pageRecord['t3ver_oid']]['sorting'];
+                    $pageRecord['t3ver_state'] = VersionState::MOVE_PLACEHOLDER;
+                } else {
+                    // Just a record in a workspace (not moved etc)
+                    $parentPageId = (int)$livePagePids[$pageRecord['t3ver_oid']];
+                }
+                // this is necessary so the links to the modules are still pointing to the live IDs
+                $pageRecord['uid'] = (int)$pageRecord['t3ver_oid'];
+                $pageRecord['pid'] = $parentPageId;
+            }
+
+            $sorting = (int)$pageRecord['sorting'];
+            while (isset($groupedAndSortedPagesByPid[$parentPageId][$sorting])) {
+                $sorting++;
+            }
+            $groupedAndSortedPagesByPid[$parentPageId][$sorting] = $pageRecord;
+        }
+
+        $this->fullPageTree = [
+            'uid' => 0,
+            'title' => $GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'] ?: 'TYPO3'
+        ];
+        $this->addChildrenToPage($this->fullPageTree, $groupedAndSortedPagesByPid);
+        return $this->fullPageTree;
+    }
+
+    /**
+     * Adds the property "_children" to a page record with the child pages
+     *
+     * @param array $page
+     * @param array[] $groupedAndSortedPagesByPid
+     */
+    protected function addChildrenToPage(array &$page, array &$groupedAndSortedPagesByPid)
+    {
+        $page['_children'] = $groupedAndSortedPagesByPid[(int)$page['uid']] ?? [];
+        ksort($page['_children']);
+        foreach ($page['_children'] as &$child) {
+            $this->addChildrenToPage($child, $groupedAndSortedPagesByPid);
+        }
+    }
+
+    /**
+     * Looking for a page by traversing the tree
+     *
+     * @param int $pageId the page ID to search for
+     * @param array $pages the page tree to look for the page
+     * @return array Array of the tree data, empty array if nothing was found
+     */
+    protected function findInPageTree(int $pageId, array $pages): array
+    {
+        foreach ($pages['_children'] as $childPage) {
+            if ((int)$childPage['uid'] === $pageId) {
+                return $childPage;
+            }
+            $result = $this->findInPageTree($pageId, $childPage);
+            if (!empty($result)) {
+                return $result;
+            }
+        }
+        return [];
+    }
+}
index d6214dc..bb8ad61 100644 (file)
@@ -234,14 +234,12 @@ define(['jquery',
 
     PageTree.prototype.hideChildren = function (node) {
       _super_.hideChildren(node);
-      var nodeIdHex = node.identifier.toString(16);
-      Persistent.set('BackendComponents.States.Pagetree.stateHash.' + nodeIdHex, 0);
+      Persistent.set('BackendComponents.States.Pagetree.stateHash.' + node.stateIdentifier, 0);
     };
 
     PageTree.prototype.showChildren = function (node) {
       _super_.showChildren(node);
-      var nodeIdHex = node.identifier.toString(16);
-      Persistent.set('BackendComponents.States.Pagetree.stateHash.' + nodeIdHex, 1);
+      Persistent.set('BackendComponents.States.Pagetree.stateHash.' + node.stateIdentifier, 1);
     };
 
     PageTree.prototype.updateNodeBgClass = function (nodeBg) {