TYPO3 v15 dev-main snapshot ()

This commit is contained in:
2026-08-10 22:31:00 +02:00
commit f9941541b7
1178 changed files with 135377 additions and 0 deletions
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Backend\Tree\Repository;
/**
* Listeners to this event will be able to modify a page with the special _children key,
* or completely change e.g. a title.
*/
final class AfterRawPageRowPreparedEvent
{
public function __construct(
private array $rawPage,
private readonly int $workspaceId
) {}
public function getRawPage(): array
{
return $this->rawPage;
}
public function setRawPage(array $rawPage): void
{
$this->rawPage = $rawPage;
}
public function getWorkspaceId(): int
{
return $this->workspaceId;
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Backend\Tree\Repository;
use TYPO3\CMS\Core\Database\Query\Expression\CompositeExpression;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
/**
* Listeners to this event will be able to modify the search parts, to be used to filter the page tree
*/
final class BeforePageTreeIsFilteredEvent
{
public function __construct(
/** @param CompositeExpression $searchParts The search parts to be used for filtering */
public CompositeExpression $searchParts,
/** @param int[] $searchUids The UIDs to be used for filtering by a special search part, which is added by Core always after listener evaluation */
public array $searchUids,
/** @param string $searchPhrase The complete search phrase, as entered by the user */
public readonly string $searchPhrase,
/** @param QueryBuilder $queryBuilder This instance is provided for context and to simplify the creation of the search parts and must not be manipulated by listeners */
public readonly QueryBuilder $queryBuilder,
) {}
}
+595
View File
@@ -0,0 +1,595 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Backend\Tree\Repository;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use TYPO3\CMS\Backend\Controller\Event\AfterPageTreeItemsPreparedEvent;
use TYPO3\CMS\Backend\Dto\Tree\Label\Label;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Routing\SiteUrlResolver;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* Page tree filter implementation providing search functionality for the backend page tree.
*
* This class implements page tree filtering through multiple event listeners that work together
* to provide comprehensive search capabilities including:
*
* - Numeric UID search (direct page ID lookup)
* - Wildcard text search in title/nav_title fields
* - Search by Frontend URI
* - Optional search in translated page titles
* - Visual labels indicating how pages were matched
*
* Overview
* ========
*
* The filtering process consists of two main phases:
*
* 1. Query Building Phase (BeforePageTreeIsFilteredEvent)
* - addUidsFromSearchPhrase: Extracts numeric UIDs from search phrase
* - addWildCardAliasFilter: Adds LIKE queries for title/nav_title
* - addUidsFromSearchPhraseWithFrontendUri: Adds numeric UIDs from resolved frontend URIs
* - addTranslatedPagesFilter: Queries translated pages (if enabled)
*
* 2. Label Attachment Phase (AfterPageTreeItemsPreparedEvent)
* - attachSearchResultLabel: Adds "Search result" label to directly matched pages
* - attachTranslationInfoLabel: Adds translation info labels
*
* Runtime Cache Usage
* ===================
*
* Two runtime caches are utilized to speed up matching.
*
* One for translation matches with the structure:
* [
* pageUid => [languageUid1, languageUid2, ...]
* ]
*
* This allows the label attachment phase to know which translations matched,
* enabling informative labels like "Found in translation: German".
*
* The cache is populated during query building and consumed during label attachment.
* Cache key: 'pageTree_translationMatches'
*
* The other is for frontend URI matches with the structure:
* [
* pageUid1 => true, pageUid2 => true, ...
* ]
*
* This allows a simple array_key_exists lookup. The cache key is 'pageTree_uriMatches'
* and also consumed for label attachment.
*
* User Configuration
* ==================
*
* Translation search can be controlled via:
* - TSConfig: options.pageTree.searchInTranslatedPages (default: true)
* - User Preference: pageTree_searchInTranslatedPages
*
* Language restrictions from user groups are respected automatically.
*
* URI search can be controlled via:
* - TSConfig: options.pageTree.searchByFrontendUri (default: true)
* - User Preference: pageTree_searchByFrontendUri
*
* @internal
*/
final readonly class PageTreeFilter
{
/**
* Color for "Search result" labels on directly matched pages
*/
private const string SEARCH_RESULT_LABEL_COLOR = '#F5A770';
/**
* Runtime cache identifier for storing translation match information
*/
private const string CACHE_IDENTIFIER_TRANSLATION = 'pageTree_translationMatches';
/**
* Runtime cache identifier for storing URI match information
*/
private const string CACHE_IDENTIFIER_URI = 'pageTree_uriMatches';
public function __construct(
private SiteFinder $siteFinder,
#[Autowire(service: 'cache.runtime')]
private FrontendInterface $runtimeCache,
private SiteUrlResolver $siteUrlResolver,
private ConnectionPool $connectionPool,
) {}
/**
* Extracts numeric page UIDs from the search phrase and adds them to the query.
*
* When a user searches for "123", this method:
* 1. Extracts the UID (123) and adds it to event->searchUids
* 2. If translation search is enabled, checks if 123 is a translated page
* 3. If yes, also adds the l10n_parent UID to show the default language page
*
* Example: Searching for UID 456 where 456 is a German translation of page 123
* will result in page 123 being shown with a "Found in translation: German" label.
*
* Supports comma-separated UIDs: "123,456,789"
*/
#[AsEventListener('page-tree-uid-provider')]
public function addUidsFromSearchPhrase(BeforePageTreeIsFilteredEvent $event): void
{
// Extract true integers from search string
$searchPhrases = GeneralUtility::trimExplode(',', $event->searchPhrase, true);
$numericUids = [];
foreach ($searchPhrases as $searchPhrase) {
if (MathUtility::canBeInterpretedAsInteger($searchPhrase) && $searchPhrase > 0) {
$uid = (int)$searchPhrase;
$event->searchUids[] = $uid;
$numericUids[] = $uid;
}
}
// Event listeners after this one may reset this array to clear unwanted UID restrictions.
$event->searchUids = array_unique($event->searchUids);
// Check if any numeric UIDs match translated pages and add their l10n_parent
if ($numericUids !== [] && $this->isTranslatedPagesSearchEnabled()) {
$queryBuilder = $this->createPreparedPagesQueryBuilder();
$whereConditions = [
$queryBuilder->expr()->in('uid', $queryBuilder->createNamedParameter($numericUids, Connection::PARAM_INT_ARRAY)),
];
$translatedPages = $this->fetchTranslatedPages($queryBuilder, $whereConditions);
$this->processTranslatedPages($event, $translatedPages);
}
}
/**
* Adds wildcard search conditions for title and nav_title fields.
*
* Creates a LIKE query that searches in both the 'title' and
* 'nav_title' fields of default language pages.
*
* Example: Searching for "Home" will find pages with:
* - title = "Homepage"
* - nav_title = "Home Navigation"
*/
#[AsEventListener(identifier: 'page-tree-wildcard-alias-filter', after: 'page-tree-uid-provider')]
public function addWildCardAliasFilter(BeforePageTreeIsFilteredEvent $event): void
{
$searchFilterWildcard = '%' . $event->queryBuilder->escapeLikeWildcards($event->searchPhrase) . '%';
$searchWhereAlias = $event->queryBuilder->expr()->or(
$event->queryBuilder->expr()->like(
'nav_title',
$event->queryBuilder->createNamedParameter($searchFilterWildcard)
),
$event->queryBuilder->expr()->like(
'title',
$event->queryBuilder->createNamedParameter($searchFilterWildcard)
)
);
$event->searchParts = $event->searchParts->with($searchWhereAlias);
}
/**
* Searches in translated page titles if translation search is enabled.
*
* Performs a separate query to find translated pages (sys_language_uid > 0)
* whose `title` or `nav_title` matches the search phrase. When matches are found,
* the `l10n_parent` pages are added to search results with language information
* stored in runtime cache.
*
* This allows finding pages like:
* - Default page "Products" with German translation "Produkte"
* - Searching for "Produkte" shows "Products" with label "Found in translation: German"
*
* Respects:
* - User's language permissions (allowed_languages from user groups)
* - TSConfig setting options.pageTree.searchInTranslatedPages
* - User preference pageTree_searchInTranslatedPages
*/
#[AsEventListener('page-tree-translated-pages-filter')]
public function addTranslatedPagesFilter(BeforePageTreeIsFilteredEvent $event): void
{
if (!$this->isTranslatedPagesSearchEnabled()) {
return;
}
$queryBuilder = $this->createPreparedPagesQueryBuilder();
$searchFilterWildcard = '%' . $event->queryBuilder->escapeLikeWildcards($event->searchPhrase) . '%';
$whereConditions = [
$queryBuilder->expr()->or(
$queryBuilder->expr()->like('title', $queryBuilder->createNamedParameter($searchFilterWildcard)),
$queryBuilder->expr()->like('nav_title', $queryBuilder->createNamedParameter($searchFilterWildcard))
),
];
$translatedPages = $this->fetchTranslatedPages($queryBuilder, $whereConditions);
$this->processTranslatedPages($event, $translatedPages);
}
/**
* Attaches "Search result" labels to pages that directly matched the search.
*
* A page "directly matched" if its language is 0 and:
* - Its UID equals the numeric search phrase
* - Its title or nav_title contains the search phrase (case-insensitive)
*
* Pages that matched via translations do NOT get this label - they
* get the translation info label instead.
*/
#[AsEventListener('page-tree-add-search-result-label')]
public function attachSearchResultLabel(AfterPageTreeItemsPreparedEvent $event): void
{
$searchPhrase = $event->getRequest()->getQueryParams()['q'] ?? '';
if (trim($searchPhrase) === '') {
return;
}
$label = $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang.xlf:pageTree.searchResult') ?: 'Search result';
$items = $event->getItems();
$searchPhraseLower = mb_strtolower($searchPhrase);
$uriMatches = $this->runtimeCache->get(self::CACHE_IDENTIFIER_URI) ?: [];
foreach ($items as &$item) {
$page = $item['_page'] ?? [];
if (!is_array($page)) {
continue;
}
$matchedDirectly = false;
// Check if search phrase is numeric and matches the page UID
if (MathUtility::canBeInterpretedAsInteger($searchPhrase) && (int)$searchPhrase === (int)($page['uid'] ?? 0)) {
$matchedDirectly = true;
}
// Check if page title or nav_title contains the search phrase
if (!$matchedDirectly) {
$title = mb_strtolower((string)($page['title'] ?? ''));
$navTitle = mb_strtolower((string)($page['nav_title'] ?? ''));
if (str_contains($title, $searchPhraseLower) || str_contains($navTitle, $searchPhraseLower)) {
$matchedDirectly = true;
}
}
if (!isset($item['labels'])) {
$item['labels'] = [];
}
if ($matchedDirectly) {
$item['labels'][] = new Label(
label: $label,
color: self::SEARCH_RESULT_LABEL_COLOR,
inheritByChildren: false,
);
}
// Through the populated uriMatches runtime cache, we check if the current item
// was matched by its frontend URI
if (array_key_exists($page['uid'], $uriMatches)) {
$label = $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang.xlf:pageTree.found_in_frontend_uri') ?: $label;
$item['labels'][] = new Label(
label: $label,
color: self::SEARCH_RESULT_LABEL_COLOR,
inheritByChildren: false,
);
// Also attach a label to indicate a translated URI
if ($uriMatches[$page['uid']]['languageUid'] !== 0) {
if ($uriMatches[$page['uid']]['languageName'] === '') {
$label = $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang.xlf:pageTree.found_translation') ?: 'Found translation';
} else {
$label = sprintf(
$this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang.xlf:pageTree.found_in_translation') ?: 'Found in translation: %s',
$uriMatches[$page['uid']]['languageName']
);
}
$item['labels'][] = new Label(
label: $label,
color: self::SEARCH_RESULT_LABEL_COLOR,
inheritByChildren: false,
);
}
}
}
unset($item);
$event->setItems($items);
}
/**
* Attaches translation info labels to pages found via translated uid / content.
*
* Reads translation match information from runtime cache (populated during
* the query building phase) and creates informative labels:
*
* - Single translation: "Found in translation: German"
* - Multiple translations: "Found in multiple translations"
*
* The language name is resolved from the site configuration when possible.
*
* Priority: 1 (shown before regular search result labels)
*/
#[AsEventListener('page-tree-add-translation-status')]
public function attachTranslationInfoLabel(AfterPageTreeItemsPreparedEvent $event): void
{
$searchPhrase = $event->getRequest()->getQueryParams()['q'] ?? '';
if (trim($searchPhrase) === '') {
return;
}
$items = $event->getItems();
foreach ($items as &$item) {
$translationLanguageUids = $item['_translationLanguageUids'] ?? [];
if (empty($translationLanguageUids)) {
continue;
}
$page = $item['_page'] ?? [];
if (!is_array($page) || !isset($page['uid'])) {
continue;
}
$pageUid = (int)$page['uid'];
// Determine label based on number of translations found
$translationCount = count($translationLanguageUids);
if ($translationCount === 1) {
// Single translation - show language name
$languageTag = $translationLanguageUids[0];
$languageName = $this->getLanguageName($pageUid, $languageTag);
if ($languageName === '') {
$label = $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang.xlf:pageTree.found_translation') ?: 'Found translation';
} else {
$label = sprintf(
$this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang.xlf:pageTree.found_in_translation') ?: 'Found in translation: %s',
$languageName
);
}
} else {
// Multiple translations
$label = $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang.xlf:pageTree.found_in_multiple_translations') ?: 'Found in multiple translations';
}
// Add a label to highlight pages found via translation
if (!isset($item['labels'])) {
$item['labels'] = [];
}
$item['labels'][] = new Label(
label: $label,
color: self::SEARCH_RESULT_LABEL_COLOR,
priority: 1,
inheritByChildren: false,
);
}
unset($item);
$event->setItems($items);
}
/**
* Find pages via their frontend URI
*/
#[AsEventListener(identifier: 'page-tree-frontend-uri-provider', after: 'page-tree-uid-provider')]
public function addUidsFromSearchPhraseWithFrontendUri(BeforePageTreeIsFilteredEvent $event): void
{
if (!$this->isFrontendUriSearchEnabled()) {
return;
}
$uriMatches = $this->runtimeCache->get(self::CACHE_IDENTIFIER_URI) ?: [];
// Extract possible frontend URIs from search string
$searchPhrases = GeneralUtility::trimExplode(',', $event->searchPhrase, true);
foreach ($searchPhrases as $searchPhrase) {
if (str_starts_with($searchPhrase, 'http://') || str_starts_with($searchPhrase, 'https://')) {
// If a search pattern uses "http(s)://...." then a frontend URL will be resolved.
$resolvedPage = $this->siteUrlResolver->resolvePageUidAndLanguageBySiteUrl($searchPhrase);
if ($resolvedPage !== null) {
$event->searchUids[] = $resolvedPage['uid'];
$uriMatches[$resolvedPage['uid']] = $resolvedPage;
}
}
}
$this->runtimeCache->set(self::CACHE_IDENTIFIER_URI, $uriMatches);
// Event listeners after this one may reset this array to clear unwanted UID restrictions.
$event->searchUids = array_unique($event->searchUids);
}
private function createPreparedPagesQueryBuilder(): QueryBuilder
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
$queryBuilder->getRestrictions()
->removeAll()
->add(GeneralUtility::makeInstance(DeletedRestriction::class));
return $queryBuilder;
}
/**
* Fetches translated pages with common base conditions plus additional WHERE clauses.
*
* Applies standard conditions that all translation queries need:
* - sys_language_uid > 0 (only translated pages)
* - l10n_parent > 0 (must have a parent page)
* - Workspace conditions (respects current workspace)
* - Language restrictions (from user group permissions)
*
* @param array $additionalConditions Extra WHERE conditions (e.g., UID match or title LIKE)
*/
private function fetchTranslatedPages(QueryBuilder $queryBuilder, array $additionalConditions): array
{
$allowedLanguages = $this->getAllowedLanguagesForCurrentUser();
$workspace = $this->getBackendUser()->workspace;
$workspaceCondition = $workspace === 0
? $queryBuilder->expr()->eq('t3ver_wsid', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT))
: $queryBuilder->expr()->in('t3ver_wsid', $queryBuilder->createNamedParameter([0, $workspace], Connection::PARAM_INT_ARRAY));
$whereConditions = [
$queryBuilder->expr()->gt('sys_language_uid', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)),
$queryBuilder->expr()->gt('l10n_parent', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)),
$workspaceCondition,
...$additionalConditions,
];
if ($allowedLanguages !== []) {
$whereConditions[] = $queryBuilder->expr()->in(
'sys_language_uid',
$queryBuilder->createNamedParameter($allowedLanguages, Connection::PARAM_INT_ARRAY)
);
}
return $queryBuilder
->select('l10n_parent', 'sys_language_uid')
->from('pages')
->where(...$whereConditions)
->executeQuery()
->fetchAllAssociative();
}
/**
* Processes translated page query results: adds parent UIDs and updates cache.
*
* For each translated page found:
* 1. Adds the l10n_parent UID to event->searchUids (avoiding duplicates)
* 2. Stores the language UID in runtime cache for later label attachment
*
* The runtime cache structure is:
* [pageUid => [languageUid1, languageUid2, ...]]
*
* @param array $translatedPages Query results with l10n_parent and sys_language_uid
*/
private function processTranslatedPages(BeforePageTreeIsFilteredEvent $event, array $translatedPages): void
{
$translationMatches = $this->runtimeCache->get(self::CACHE_IDENTIFIER_TRANSLATION) ?: [];
$addedParents = [];
foreach ($translatedPages as $translatedPage) {
$l10nParent = (int)$translatedPage['l10n_parent'];
$languageTag = $translatedPage['language_tag'] ?? '';
// Add parent UID to search results (avoiding duplicates)
if (!isset($addedParents[$l10nParent])) {
$event->searchUids[] = $l10nParent;
$addedParents[$l10nParent] = true;
}
// Store translation match in runtime cache
if (!isset($translationMatches[$l10nParent])) {
$translationMatches[$l10nParent] = [];
}
if (!in_array($languageTag, $translationMatches[$l10nParent], true)) {
$translationMatches[$l10nParent][] = $languageTag;
}
}
$this->runtimeCache->set(self::CACHE_IDENTIFIER_TRANSLATION, $translationMatches);
}
private function getLanguageName(int $pageUid, string $languageTag): string
{
try {
$site = $this->siteFinder->getSiteByPageId($pageUid);
foreach ($site->getAllLanguages() as $language) {
if ($language->getLanguageTag() === $languageTag) {
return $language->getTitle();
}
}
return $languageTag;
} catch (\Exception) {
return $languageTag;
}
}
/**
* Checks if translation search is enabled for the current user.
*
* Checks:
* - TSConfig options.pageTree.searchInTranslatedPages
* - User preference pageTree_searchInTranslatedPages
*/
private function isTranslatedPagesSearchEnabled(): bool
{
$backendUser = $this->getBackendUser();
// If feature is disabled, always return false
$translationSearchAvailable = (bool)($backendUser->getTSConfig()['options.']['pageTree.']['searchInTranslatedPages'] ?? true);
if (!$translationSearchAvailable) {
return false;
}
// If feature is available, check user preference
if (isset($backendUser->uc['pageTree_searchInTranslatedPages'])) {
return (bool)$backendUser->uc['pageTree_searchInTranslatedPages'];
}
return true;
}
/**
* Checks if frontend URI search is enabled for the current user.
*
* Checks:
* - TSConfig options.pageTree.searchByFrontendUri
* - User preference pageTree_searchByFrontendUri
*/
private function isFrontendUriSearchEnabled(): bool
{
$backendUser = $this->getBackendUser();
// If feature is disabled, always return false
$frontendUriSearchAvailable = (bool)($backendUser->getTSConfig()['options.']['pageTree.']['searchByFrontendUri'] ?? true);
if (!$frontendUriSearchAvailable) {
return false;
}
// If feature is available, check user preference
if (isset($backendUser->uc['pageTree_searchByFrontendUri'])) {
return (bool)$backendUser->uc['pageTree_searchByFrontendUri'];
}
return true;
}
private function getAllowedLanguagesForCurrentUser(): array
{
$allowedLanguages = trim($this->getBackendUser()->groupData['allowed_languages'] ?? '');
return $allowedLanguages !== '' ? GeneralUtility::intExplode(',', $allowedLanguages) : [];
}
private function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
private function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,816 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Backend\Tree\Repository;
use Psr\EventDispatcher\EventDispatcherInterface;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Cache\CacheManager;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\QueryHelper;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction;
use TYPO3\CMS\Core\DataHandling\PlainDataResolver;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
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 readonly array $fields;
/**
* The fields array, quoted for repeated use in recursive pages queries, to avoid the need to newly
* quote the fields for each single query (which can get really expensive for a large amount of fields)
* @var string[]
*/
protected readonly array $quotedFields;
/**
* The workspace ID to operate on
*/
protected readonly int $currentWorkspace;
/**
* Full page tree when selected without permissions applied.
*/
protected array $fullPageTree = [];
protected readonly array $additionalQueryRestrictions;
protected ?string $additionalWhereClause = null;
protected ?int $languageFilter = null;
protected string $defaultLanguageTag;
protected EventDispatcherInterface $eventDispatcher;
protected readonly FrontendInterface $runtimeCache;
/**
* @param int $workspaceId the workspace ID to be checked for.
* @param array $additionalFieldsToQuery an array with more fields that should be accessed.
* @param array $additionalQueryRestrictions an array with more restrictions to add
*/
public function __construct(int $workspaceId = 0, array $additionalFieldsToQuery = [], array $additionalQueryRestrictions = [])
{
$this->defaultLanguageTag = \Local\Multilanguage\Service\DefaultLanguageTagService::getTag();
$this->currentWorkspace = $workspaceId;
$this->fields = array_merge([
'uid',
'pid',
'sorting',
'starttime',
'endtime',
'hidden',
'fe_group',
'title',
'nav_title',
'nav_hide',
'php_tree_stop',
'doktype',
'is_siteroot',
'module',
'extendToSubpages',
'content_from_pid',
't3ver_oid',
't3ver_wsid',
't3ver_state',
't3ver_stage',
'perms_userid',
'perms_user',
'perms_groupid',
'perms_group',
'perms_everybody',
'mount_pid',
'shortcut',
'shortcut_mode',
'mount_pid_ol',
'link',
'sys_language_uid',
'l10n_parent',
'language_tag',
], $additionalFieldsToQuery);
$this->additionalQueryRestrictions = $additionalQueryRestrictions;
// @todo: use DI in the future
$this->eventDispatcher = GeneralUtility::makeInstance(EventDispatcherInterface::class);
$this->runtimeCache = GeneralUtility::makeInstance(CacheManager::class)->getCache('runtime');
$this->quotedFields = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('pages')
->quoteIdentifiersForSelect($this->fields);
}
public function setLanguageFilter(?int $languageUid): void
{
$this->languageFilter = $languageUid;
}
public function setAdditionalWhereClause(string $additionalWhereClause): void
{
$this->additionalWhereClause = $additionalWhereClause;
}
private function getLanguageCondition($queryBuilder): void
{
$queryBuilder->andWhere(
$queryBuilder->expr()->or(
$queryBuilder->expr()->eq('language_tag', $queryBuilder->createNamedParameter($this->defaultLanguageTag)),
$queryBuilder->expr()->or(
$queryBuilder->expr()->eq('language_tag', $queryBuilder->createNamedParameter('')),
$queryBuilder->expr()->isNull('language_tag')
)
)
);
}
/**
* Get translation language UIDs that matched for a specific page UID
*
* @return int[]
*/
public function getTranslationMatches(int $pageUid): array
{
$translationMatches = $this->runtimeCache->get('pageTree_translationMatches') ?: [];
return $translationMatches[$pageUid] ?? [];
}
/**
* 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|null $callback a callback to be used to check for permissions and filter out pages not to be included.
*/
public function getTree(
int $entryPoint,
?callable $callback = null,
array $dbMounts = []
): array {
$this->fetchAllPages($dbMounts);
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
*/
protected function applyCallbackToChildren(array &$tree, callable $callback): void
{
if (!isset($tree['_children'])) {
return;
}
foreach ($tree['_children'] as $k => &$childPage) {
if (!$callback($childPage)) {
unset($tree['_children'][$k]);
continue;
}
$this->applyCallbackToChildren($childPage, $callback);
}
}
/**
* Get the page tree based on a given page record and a given depth
*
* @param array $pageTree The page record of the top level page you want to get the page tree of
* @param int $depth Number of levels to fetch
* @param ?array $entryPointIds entryPointIds to include (null in case no entry-points were provided)
* @return array An array with page records and their children
*/
public function getTreeLevels(array $pageTree, int $depth, ?array $entryPointIds = null): array
{
$groupedAndSortedPagesByPid = [];
// the method was called without any entry-point information
if ($entryPointIds === null) {
$parentPageIds = [$pageTree['uid']];
// the method was called with entry-point information, that is not empty
} elseif ($entryPointIds !== []) {
$pageRecords = $this->getPageRecords($entryPointIds);
$groupedAndSortedPagesByPid[$pageTree['uid']] = $pageRecords;
$parentPageIds = $entryPointIds;
}
for ($i = 0; $i < $depth; $i++) {
// stop in case the initial or recursive query did not have any pages
if (empty($parentPageIds)) {
break;
}
$pageRecords = $this->getChildPageRecords($parentPageIds);
$groupedAndSortedPagesByPid = $this->groupAndSortPages($pageRecords, $groupedAndSortedPagesByPid);
$parentPageIds = array_column($pageRecords, 'uid');
}
$this->addChildrenToPage($pageTree, $groupedAndSortedPagesByPid);
return $pageTree;
}
/**
* Useful to get a list of pages, with a specific depth, e.g. to limit
* a query to another table to a list of page IDs.
*
* @param int[] $entryPointIds
*/
public function getFlattenedPages(array $entryPointIds, int $depth): array
{
$allPageRecords = $this->getPageRecords($entryPointIds);
$parentPageIds = $entryPointIds;
for ($i = 0; $i < $depth; $i++) {
if (empty($parentPageIds)) {
break;
}
$pageRecords = $this->getChildPageRecords($parentPageIds);
$parentPageIds = array_column($pageRecords, 'uid');
$allPageRecords = array_merge($allPageRecords, $pageRecords);
}
return $allPageRecords;
}
protected function getChildPageRecords(array $parentPageIds): array
{
return $this->getPageRecords([], $parentPageIds);
}
/**
* Retrieve the page records based on the given page or parent page ids
*/
protected function getPageRecords(array $pageIds = [], array $parentPageIds = []): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('pages');
$queryBuilder->getRestrictions()
->removeAll()
->add(GeneralUtility::makeInstance(DeletedRestriction::class))
->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->currentWorkspace));
if (!empty($this->additionalQueryRestrictions)) {
foreach ($this->additionalQueryRestrictions as $additionalQueryRestriction) {
$queryBuilder->getRestrictions()->add($additionalQueryRestriction);
}
}
$queryBuilder->getConcreteQueryBuilder()->select(...$this->quotedFields);
$queryBuilder
->from('pages')
->where('1=1');
$this->getLanguageCondition($queryBuilder);
$queryBuilder
// ensure deterministic sorting
->orderBy('sorting', 'ASC')
->addOrderBy('uid', 'ASC');
if (!empty($this->additionalWhereClause)) {
$queryBuilder->andWhere(
QueryHelper::stripLogicalOperatorPrefix($this->additionalWhereClause)
);
}
if (count($pageIds) > 0) {
$queryBuilder->andWhere(
$queryBuilder->expr()->in('uid', $queryBuilder->createNamedParameter($pageIds, Connection::PARAM_INT_ARRAY))
);
}
if (count($parentPageIds) > 0) {
$queryBuilder->andWhere(
$queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter($parentPageIds, Connection::PARAM_INT_ARRAY))
);
}
$pageRecords = $queryBuilder
->executeQuery()
->fetchAllAssociative();
// This is necessary to resolve all IDs in a workspace
if ($this->currentWorkspace !== 0 && !empty($pageRecords)) {
$livePageIds = [];
$movedPages = [];
foreach ($pageRecords as $pageRecord) {
$livePageIds[] = (int)$pageRecord['uid'];
if (VersionState::tryFrom($pageRecord['t3ver_state'] ?? 0) === VersionState::MOVE_POINTER) {
$movedPages[$pageRecord['t3ver_oid']] = [
'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();
if (!empty($recordIds)) {
$queryBuilder->getRestrictions()->removeAll();
$queryBuilder->getConcreteQueryBuilder()->select(...$this->quotedFields);
$pageRecords = $queryBuilder
->from('pages')
->where(
$queryBuilder->expr()->in('uid', $queryBuilder->createNamedParameter($recordIds, Connection::PARAM_INT_ARRAY))
)
// ensure deterministic sorting
->orderBy('sorting', 'ASC')
->addOrderBy('uid', 'ASC')
->executeQuery()
->fetchAllAssociative();
foreach ($pageRecords as &$pageRecord) {
if (VersionState::tryFrom($pageRecord['t3ver_state'] ?? 0) === VersionState::MOVE_POINTER && !empty($movedPages[$pageRecord['t3ver_oid']])) {
$pageRecord['uid'] = $pageRecord['t3ver_oid'];
$pageRecord['sorting'] = (int)$movedPages[$pageRecord['t3ver_oid']]['sorting'];
$pageRecord['pid'] = (int)$movedPages[$pageRecord['t3ver_oid']]['pid'];
} elseif ((int)$pageRecord['t3ver_oid'] > 0) {
$liveRecord = BackendUtility::getRecord('pages', $pageRecord['t3ver_oid']);
$pageRecord['sorting'] = (int)$liveRecord['sorting'];
$pageRecord['uid'] = (int)$liveRecord['uid'];
$pageRecord['pid'] = (int)$liveRecord['pid'];
}
}
unset($pageRecord);
} else {
$pageRecords = [];
}
}
foreach ($pageRecords as &$pageRecord) {
$pageRecord['uid'] = (int)$pageRecord['uid'];
}
return $pageRecords;
}
public function hasChildren(int $pid): bool
{
$pageRecords = $this->getChildPageRecords([$pid]);
return !empty($pageRecords);
}
/**
* Fetch all non-deleted pages, regardless of permissions (however, considers additionalQueryRestrictions and additionalWhereClause).
* That's why it's internal.
*
* @return array the full page tree of the whole installation
*/
protected function fetchAllPages(array $dbMounts): 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(WorkspaceRestriction::class, $this->currentWorkspace));
if (!empty($this->additionalQueryRestrictions)) {
foreach ($this->additionalQueryRestrictions as $additionalQueryRestriction) {
$queryBuilder->getRestrictions()->add($additionalQueryRestriction);
}
}
$queryBuilder->getConcreteQueryBuilder()->select(...$this->quotedFields);
$query = $queryBuilder
->from('pages')
->where('1=1');
$this->getLanguageCondition($queryBuilder);
if (!empty($this->additionalWhereClause)) {
$queryBuilder->andWhere(
QueryHelper::stripLogicalOperatorPrefix($this->additionalWhereClause)
);
}
$pageRecords = $query->executeQuery()->fetchAllAssociative();
$ids = array_column($pageRecords, 'uid');
foreach ($dbMounts as $mount) {
$entryPointRootLine = BackendUtility::BEgetRootLine($mount, '', false, $this->fields);
foreach ($entryPointRootLine as $page) {
$pageId = (int)$page['uid'];
if (in_array($pageId, $ids) || $pageId === 0) {
continue;
}
$pageRecords[] = $page;
$ids[] = $pageId;
}
}
$livePagePids = [];
$movedPages = [];
// This is necessary to resolve all IDs in a workspace
if ($this->currentWorkspace !== 0 && !empty($pageRecords)) {
$livePageIds = [];
foreach ($pageRecords as $pageRecord) {
$livePageIds[] = (int)$pageRecord['uid'];
$livePagePids[(int)$pageRecord['uid']] = (int)$pageRecord['pid'];
if (VersionState::tryFrom($pageRecord['t3ver_state'] ?? 0) === VersionState::MOVE_POINTER) {
$movedPages[$pageRecord['t3ver_oid']] = [
'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();
$queryBuilder->getConcreteQueryBuilder()->select(...$this->quotedFields);
$pageRecords = $queryBuilder
->from('pages')
->where(
$queryBuilder->expr()->in('uid', $recordIds)
)
->executeQuery()
->fetchAllAssociative();
}
// 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 ((int)$pageRecord['t3ver_oid'] > 0) {
// When a move pointer is found, the pid+sorting of the versioned record should be used
if (VersionState::tryFrom($pageRecord['t3ver_state'] ?? 0) === VersionState::MOVE_POINTER && !empty($movedPages[$pageRecord['t3ver_oid']])) {
$parentPageId = (int)$movedPages[$pageRecord['t3ver_oid']]['pid'];
$pageRecord['sorting'] = (int)$movedPages[$pageRecord['t3ver_oid']]['sorting'];
} else {
// Just a record in a workspace (not moved etc)
$parentPageId = (int)($livePagePids[$pageRecord['t3ver_oid']] ?? $pageRecord['pid']);
}
// 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[] $groupedAndSortedPagesByPid
*/
protected function addChildrenToPage(array &$page, array &$groupedAndSortedPagesByPid): void
{
$page['_children'] = $groupedAndSortedPagesByPid[(int)$page['uid']] ?? [];
ksort($page['_children']);
$event = $this->eventDispatcher->dispatch(new AfterRawPageRowPreparedEvent($page, $this->currentWorkspace));
$page = $event->getRawPage();
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 [];
}
/**
* Retrieve the page tree based on the given search filter
*/
public function fetchFilteredTree(string $searchFilter, array $allowedMountPointPageIds, string $additionalWhereClause): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('pages');
$queryBuilder->getRestrictions()
->removeAll()
->add(GeneralUtility::makeInstance(DeletedRestriction::class));
if (!empty($this->additionalQueryRestrictions)) {
foreach ($this->additionalQueryRestrictions as $additionalQueryRestriction) {
$queryBuilder->getRestrictions()->add($additionalQueryRestriction);
}
}
$expressionBuilder = $queryBuilder->expr();
if ($this->currentWorkspace === 0) {
// Only include records from live workspace
$workspaceIdExpression = $expressionBuilder->eq('t3ver_wsid', 0);
} else {
// Include live records PLUS records from the given workspace
$workspaceIdExpression = $expressionBuilder->in(
't3ver_wsid',
$queryBuilder->createNamedParameter([0, $this->currentWorkspace], Connection::PARAM_INT_ARRAY)
);
}
$queryBuilder->getConcreteQueryBuilder()->select(...$this->quotedFields);
$queryBuilder = $queryBuilder
->from('pages')
->where(
$workspaceIdExpression,
QueryHelper::stripLogicalOperatorPrefix($additionalWhereClause)
);
$this->getLanguageCondition($queryBuilder);
// Clear translation matches cache for new search
$this->runtimeCache->remove('pageTree_translationMatches');
// Allow to extend search parts and search uids
$event = $this->eventDispatcher->dispatch(
new BeforePageTreeIsFilteredEvent($expressionBuilder->or(), [], $searchFilter, $queryBuilder)
);
$searchParts = $event->searchParts;
$searchUids = $event->searchUids;
if (!empty($searchUids)) {
// Ensure that the LIVE id is also found
if ($this->currentWorkspace > 0) {
$uidConditions = [
// Check for UID of live record
$expressionBuilder->and(
$expressionBuilder->in('uid', $queryBuilder->createNamedParameter($searchUids, Connection::PARAM_INT_ARRAY)),
$expressionBuilder->eq('t3ver_wsid', 0),
),
// Check for UID of live record in versioned record
$expressionBuilder->and(
$expressionBuilder->in('t3ver_oid', $queryBuilder->createNamedParameter($searchUids, Connection::PARAM_INT_ARRAY)),
$expressionBuilder->eq('t3ver_wsid', $queryBuilder->createNamedParameter($this->currentWorkspace, Connection::PARAM_INT)),
),
];
// Check for UID for new or moved versioned record (only if searchFilter is numeric)
if (MathUtility::canBeInterpretedAsInteger($searchFilter)) {
$uidConditions[] = $expressionBuilder->and(
$expressionBuilder->eq('uid', $queryBuilder->createNamedParameter((int)$searchFilter, Connection::PARAM_INT)),
$expressionBuilder->eq('t3ver_oid', 0),
$expressionBuilder->eq('t3ver_wsid', $queryBuilder->createNamedParameter($this->currentWorkspace, Connection::PARAM_INT)),
);
}
$uidFilter = $expressionBuilder->or(...$uidConditions);
} else {
$uidFilter = $expressionBuilder->in('uid', $queryBuilder->createNamedParameter($searchUids, Connection::PARAM_INT_ARRAY));
}
$searchParts = $searchParts->with($uidFilter);
}
$queryBuilder->andWhere($searchParts);
$pageRecords = $queryBuilder
->executeQuery()
->fetchAllAssociative();
$livePagePids = [];
if ($this->currentWorkspace !== 0 && !empty($pageRecords)) {
$livePageIds = [];
foreach ($pageRecords as $pageRecord) {
$livePageIds[] = (int)$pageRecord['uid'];
$livePagePids[(int)$pageRecord['uid']] = (int)$pageRecord['pid'];
if ((int)$pageRecord['t3ver_oid'] > 0) {
$livePagePids[(int)$pageRecord['t3ver_oid']] = (int)$pageRecord['pid'];
}
if (VersionState::tryFrom($pageRecord['t3ver_state'] ?? 0) === VersionState::MOVE_POINTER) {
$movedPages[$pageRecord['t3ver_oid']] = [
'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();
$pageRecords = [];
if (!empty($recordIds)) {
$queryBuilder->getRestrictions()->removeAll();
$queryBuilder->getConcreteQueryBuilder()->select(...$this->quotedFields);
$queryBuilder
->from('pages')
->where(
$queryBuilder->expr()->in('uid', $queryBuilder->createNamedParameter($recordIds, Connection::PARAM_INT_ARRAY))
);
$queryBuilder->andWhere($searchParts);
$pageRecords = $queryBuilder
->executeQuery()
->fetchAllAssociative();
}
}
$pages = [];
foreach ($pageRecords as $pageRecord) {
// 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 ((int)$pageRecord['t3ver_oid'] > 0) {
// This probably should also remove the live version
$versionState = VersionState::tryFrom($pageRecord['t3ver_state'] ?? 0);
if ($versionState === VersionState::DELETE_PLACEHOLDER) {
continue;
}
// When a move pointer is found, the pid+sorting of the versioned record be used
if ($versionState === VersionState::MOVE_POINTER && !empty($movedPages[$pageRecord['t3ver_oid']])) {
$parentPageId = (int)$movedPages[$pageRecord['t3ver_oid']]['pid'];
$pageRecord['sorting'] = (int)$movedPages[$pageRecord['t3ver_oid']]['sorting'];
} 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;
}
$pages[(int)$pageRecord['uid']] = $pageRecord;
}
unset($pageRecords);
$pages = $this->filterPagesOnMountPoints($pages, $allowedMountPointPageIds);
$groupedAndSortedPagesByPid = $this->groupAndSortPages($pages);
$this->fullPageTree = [
'uid' => 0,
'title' => $GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'] ?: 'TYPO3',
];
$this->addChildrenToPage($this->fullPageTree, $groupedAndSortedPagesByPid);
return $this->fullPageTree;
}
/**
* Filter all records outside of the allowed mount points
*/
protected function filterPagesOnMountPoints(array $pages, array $mountPoints): array
{
foreach ($pages as $key => $pageRecord) {
$rootline = BackendUtility::BEgetRootLine(
$pageRecord['uid'],
'',
$this->currentWorkspace !== 0,
$this->fields
);
$rootline = array_reverse($rootline);
if (!in_array(0, $mountPoints, true)) {
$isInsideMountPoints = false;
foreach ($rootline as $rootlineElement) {
if (in_array((int)$rootlineElement['uid'], $mountPoints, true)) {
$isInsideMountPoints = true;
break;
}
}
if (!$isInsideMountPoints) {
unset($pages[$key]);
//skip records outside of the allowed mount points
continue;
}
}
$inFilteredRootline = false;
$amountOfRootlineElements = count($rootline);
for ($i = 0; $i < $amountOfRootlineElements; ++$i) {
$rootlineElement = $rootline[$i];
$rootlineElement['uid'] = (int)$rootlineElement['uid'];
$isInWebMount = false;
if ($rootlineElement['uid'] > 0) {
$isInWebMount = (int)$this->getBackendUser()->isInWebMount($rootlineElement);
}
if (!$isInWebMount
|| ($rootlineElement['uid'] === (int)$mountPoints[0]
&& $rootlineElement['uid'] !== $isInWebMount)
) {
continue;
}
if ($this->getBackendUser()->isAdmin() || ($rootlineElement['uid'] === $isInWebMount && in_array($rootlineElement['uid'], $mountPoints, true))) {
$inFilteredRootline = true;
}
if (!$inFilteredRootline) {
continue;
}
if (!isset($pages[$rootlineElement['uid']])) {
$pages[$rootlineElement['uid']] = $rootlineElement;
}
}
}
// Make sure the mountpoints show up in page tree even when parent pages are not accessible pages
foreach ($mountPoints as $mountPoint) {
if ($mountPoint !== 0) {
if (!array_key_exists($mountPoint, $pages)) {
$pages[$mountPoint] = BackendUtility::getRecordWSOL('pages', $mountPoint);
$pages[$mountPoint]['uid'] = (int)$pages[$mountPoint]['uid'];
}
$pages[$mountPoint]['pid'] = 0;
}
}
return $pages;
}
/**
* Group pages by parent page and sort pages based on sorting property
*/
protected function groupAndSortPages(array $pages, array $groupedAndSortedPagesByPid = []): array
{
foreach ($pages as $pageRecord) {
$parentPageId = (int)$pageRecord['pid'];
$sorting = (int)$pageRecord['sorting'];
// If the page record was already added in another depth level, don't add it another time.
// This may happen, if entry points are intersecting each other (Entry point B is inside entry point A).
if (($groupedAndSortedPagesByPid[$parentPageId][$sorting]['uid'] ?? 0) === $pageRecord['uid']) {
continue;
}
while (isset($groupedAndSortedPagesByPid[$parentPageId][$sorting])) {
$sorting++;
}
$groupedAndSortedPagesByPid[$parentPageId][$sorting] = $pageRecord;
}
return $groupedAndSortedPagesByPid;
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}