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
+71
View File
@@ -0,0 +1,71 @@
<?php
/*
* 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;
use TYPO3\CMS\Backend\Tree\Renderer\AbstractTreeRenderer;
/**
* Abstract Tree
*/
abstract class AbstractTree
{
/**
* Data Provider
*
* @var \TYPO3\CMS\Backend\Tree\AbstractTreeDataProvider
*/
protected $dataProvider;
/**
* Tree Node Decorator
*
* @var \TYPO3\CMS\Backend\Tree\Renderer\AbstractTreeRenderer
*/
protected $nodeRenderer;
public function setDataProvider(AbstractTreeDataProvider $dataProvider)
{
$this->dataProvider = $dataProvider;
}
/**
* @return \TYPO3\CMS\Backend\Tree\AbstractTreeDataProvider
*/
public function getDataProvider()
{
return $this->dataProvider;
}
public function setNodeRenderer(AbstractTreeRenderer $nodeRenderer)
{
$this->nodeRenderer = $nodeRenderer;
}
/**
* @return \TYPO3\CMS\Backend\Tree\Renderer\AbstractTreeRenderer
*/
public function getNodeRenderer()
{
return $this->nodeRenderer;
}
/**
* Returns the root node
*
* @return \TYPO3\CMS\Backend\Tree\TreeNode
*/
abstract public function getRoot();
}
+36
View File
@@ -0,0 +1,36 @@
<?php
/*
* 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;
/**
* Abstract Tree Data Provider
*/
abstract class AbstractTreeDataProvider
{
/**
* Root Node
*
* @var TreeNode
*/
protected $rootNode;
/**
* Returns the root node
*
* @return TreeNode
*/
abstract public function getRoot();
}
+46
View File
@@ -0,0 +1,46 @@
<?php
/*
* 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;
/**
* Interface that defines the comparison of nodes
*/
interface ComparableNodeInterface
{
/**
* Compare Node against another one
*
* Returns:
* 1 if the current node is greater than the $other,
* -1 if $other is greater than the current node and
* 0 if the nodes are equal
*
* <strong>Example</strong>
* <pre>
* if ($this->sortValue > $other->sortValue) {
* return 1;
* } elseif ($this->sortValue < $other->sortValue) {
* return -1;
* } else {
* return 0;
* }
* </pre>
*
* @param \TYPO3\CMS\Backend\Tree\TreeNode $other
* @return int see description
*/
public function compareTo($other);
}
+238
View File
@@ -0,0 +1,238 @@
<?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;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Resource\Driver\DriverInterface;
use TYPO3\CMS\Core\Resource\Exception\InsufficientFolderAccessPermissionsException;
use TYPO3\CMS\Core\Resource\Exception\InsufficientFolderReadPermissionsException;
use TYPO3\CMS\Core\Resource\Folder;
use TYPO3\CMS\Core\Resource\FolderInterface;
use TYPO3\CMS\Core\Resource\InaccessibleFolder;
use TYPO3\CMS\Core\Resource\ResourceStorage;
use TYPO3\CMS\Core\Resource\Utility\ListUtility;
/**
* Responsible for fetching a tree-structure of folders.
*
* @internal not part of TYPO3 Core API due to the specific use case for the FileStorageTree component.
*/
readonly class FileStorageTreeProvider
{
public function prepareFolderInformation(Folder $folder, ?string $alternativeName = null, ?Folder $parentFolder = null, ?array $children = null): array
{
$name = $alternativeName ?? $folder->getName();
$storage = $folder->getStorage();
try {
$parentFolder = $parentFolder ?? $folder->getParentFolder();
} catch (InsufficientFolderAccessPermissionsException) {
$parentFolder = null;
}
if (str_contains($folder->getRole(), FolderInterface::ROLE_MOUNT)) {
$tableName = 'sys_filemount';
$isStorage = true;
} elseif ($parentFolder === null || $folder->getIdentifier() === $storage->getRootLevelFolder()->getIdentifier()) {
$tableName = 'sys_file_storage';
$isStorage = true;
} else {
$tableName = 'sys_file';
$isStorage = false;
}
try {
$hasSubfolders = $storage->isBrowsable() && (is_array($children) ? $children !== [] : !empty($folder->getSubfolders()));
} catch (\InvalidArgumentException|InsufficientFolderReadPermissionsException) {
$hasSubfolders = false;
}
return [
'resource' => $folder,
'identifier' => rawurlencode($folder->getCombinedIdentifier()),
'name' => $name,
'storage' => $storage->getUid(),
'pathIdentifier' => rawurlencode($folder->getIdentifier()),
'hasChildren' => $hasSubfolders,
'parentIdentifier' => !$isStorage ? rawurlencode($parentFolder->getCombinedIdentifier()) : null,
'recordType' => $tableName,
];
}
/**
* Fetch all file storages / file mounts visible for a user.
*/
public function getRootNodes(BackendUserAuthentication $user): array
{
$items = [];
$storages = $user->getFileStorages();
foreach ($storages as $storageObject) {
$items = array_merge($items, $this->getFoldersInStorage($storageObject, $user));
}
return $items;
}
/**
* Fetch all folders recursively in a single store.
*/
protected function getFoldersInStorage(ResourceStorage $resourceStorage, BackendUserAuthentication $user): array
{
$rootLevelFolders = $this->getMountsInStorage($resourceStorage, $user);
$items = [];
foreach ($rootLevelFolders as $rootLevelFolderInfo) {
/** @var Folder $rootLevelFolder */
$rootLevelFolder = $rootLevelFolderInfo['folder'];
$item = $this->prepareFolderInformation($rootLevelFolder, $rootLevelFolderInfo['name']);
$item['depth'] = 0;
$item['expanded'] = true;
$item['loaded'] = true;
$items[] = $item;
if ($resourceStorage->isBrowsable()) {
// Handle sub folders if the mount is browsable.
$childItems = $this->getSubfolders($rootLevelFolder, 1);
array_push($items, ...$childItems);
}
}
return $items;
}
/**
* Filter a tree by a search word
*
* @return FolderInterface[]
* @throws \Exception
*/
public function getFilteredTree(BackendUserAuthentication $user, string $search): array
{
$foundFolders = [];
$storages = $user->getFileStorages();
foreach ($storages as $resourceStorage) {
$processingFolders = $resourceStorage->getProcessingFolders();
$processingFolderIdentifiers = array_map(static function (Folder $folder): string {
return $folder->getIdentifier();
}, $processingFolders);
$resourceStorage->addFileAndFolderNameFilter(static function ($itemName, $itemIdentifier, $parentIdentifier, array $additionalInformation, DriverInterface $driver) use ($resourceStorage, $search, $processingFolderIdentifiers) {
// Skip items in processing folders
$isInProcessingFolder = array_filter($processingFolderIdentifiers, static function (string $processingFolderIdentifier) use ($parentIdentifier): bool {
return stripos($parentIdentifier, $processingFolderIdentifier) !== false;
});
if (!empty($isInProcessingFolder)) {
return -1;
}
if ($itemName instanceof Folder) {
if ($resourceStorage->isProcessingFolder($itemName)) {
return -1;
}
$name = $itemName->getName();
} elseif (is_string($itemName)) {
$name = $itemName;
} else {
return -1;
}
if (stripos($name, $search) !== false) {
return true;
}
return -1;
});
try {
$files = $folders = [];
// Because $resourceStorage->getRootLevelFolder() does not return an actual root folder but
// the first file mount, we first need to check if we have file mounts and then fetch them one by one.
if (($fileMounts = $resourceStorage->getFileMounts()) !== []) {
foreach ($fileMounts as $identifier => $configuration) {
foreach ($resourceStorage->getFilesInFolder($resourceStorage->getFolder($identifier), 0, 0, true, true) as $file) {
$files[] = $file;
}
foreach ($resourceStorage->getFolderIdentifiersInFolder($identifier, true, true) as $folder) {
$folders[] = $folder;
}
}
} else {
$files = $resourceStorage->getFilesInFolder($resourceStorage->getRootLevelFolder(), 0, 0, true, true);
$folders = $resourceStorage->getFolderIdentifiersInFolder($resourceStorage->getRootLevelFolder()->getIdentifier(), true, true);
}
foreach ($files as $file) {
$folder = $file->getParentFolder();
$foundFolders[$folder->getCombinedIdentifier()] = $folder;
}
foreach ($folders as $folder) {
$folderObj = $resourceStorage->getFolder($folder);
$foundFolders[$folderObj->getCombinedIdentifier()] = $folderObj;
}
} catch (InsufficientFolderAccessPermissionsException $e) {
// do nothing
}
$resourceStorage->resetFileAndFolderNameFiltersToDefault();
}
return $foundFolders;
}
public function getSubfolders(Folder $folderObject, int $currentDepth): array
{
$items = [];
$subFolders = [];
if (!$folderObject instanceof InaccessibleFolder) {
$subFolders = $folderObject->getSubfolders();
$subFolders = ListUtility::resolveSpecialFolderNames($subFolders);
uksort($subFolders, strnatcasecmp(...));
}
foreach ($subFolders as $subFolderName => $subFolder) {
$subFolderName = (string)$subFolderName; // Enforce string cast in case $subFolderName contains numeric chars only
$children = [];
if (!($subFolder instanceof InaccessibleFolder)) {
// Get children to determine if that folder can be expanded again, 'hasChildren true' then renders expand triangle
$children = $subFolder->getSubfolders();
}
$items[] = array_merge(
$this->prepareFolderInformation($subFolder, $subFolderName, $folderObject, $children),
[
'depth' => $currentDepth,
'expanded' => false,
'loaded' => false,
]
);
}
return $items;
}
/**
* Fetches all "root level folders" of a storage. If a user has file mounts in this storage, they are properly resolved.
*
* @return array|array[]
*/
protected function getMountsInStorage(ResourceStorage $resourceStorage, BackendUserAuthentication $user): array
{
$fileMounts = $resourceStorage->getFileMounts();
if (!empty($fileMounts)) {
return array_map(static function (array $fileMountInfo): array {
return [
'folder' => $fileMountInfo['folder'],
'name' => $fileMountInfo['title'],
];
}, $fileMounts);
}
if ($user->isAdmin()) {
return [
[
'folder' => $resourceStorage->getRootLevelFolder(),
'name' => $resourceStorage->getName(),
],
];
}
return [];
}
}
@@ -0,0 +1,50 @@
<?php
/*
* 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\Renderer;
use TYPO3\CMS\Backend\Tree\AbstractTree;
use TYPO3\CMS\Backend\Tree\TreeNodeCollection;
use TYPO3\CMS\Backend\Tree\TreeRepresentationNode;
/**
* Abstract Renderer
*/
abstract class AbstractTreeRenderer
{
/**
* Renders a node recursive or just a single instance
*
* @param bool $recursive
* @return mixed
*/
abstract public function renderNode(TreeRepresentationNode $node, $recursive = true);
/**
* Renders a node collection recursive or just a single instance
*
* @param bool $recursive
* @return mixed
*/
abstract public function renderNodeCollection(TreeNodeCollection $collection, $recursive = true);
/**
* Renders a tree recursively or just a single instance
*
* @param bool $recursive
* @return mixed
*/
abstract public function renderTree(AbstractTree $tree, $recursive = true);
}
@@ -0,0 +1,85 @@
<?php
/*
* 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\Renderer;
use TYPO3\CMS\Backend\Tree\AbstractTree;
use TYPO3\CMS\Backend\Tree\TreeNodeCollection;
use TYPO3\CMS\Backend\Tree\TreeRepresentationNode;
/**
* Renderer for unordered lists
*/
class UnorderedListTreeRenderer extends AbstractTreeRenderer
{
/**
* recursion level
*
* @var int
*/
protected $recursionLevel = 0;
/**
* Renders a node recursive or just a single instance
*
* @param bool $recursive
* @return string
*/
public function renderNode(TreeRepresentationNode $node, $recursive = true)
{
$code = '<li><span class="' . htmlspecialchars($node->getIcon()) . '">&nbsp;</span>' . htmlspecialchars($node->getLabel());
if ($recursive && $node->hasChildNodes()) {
$this->recursionLevel++;
$code .= $this->renderNodeCollection($node->getChildNodes());
$this->recursionLevel--;
}
$code .= '</li>';
return $code;
}
/**
* Renders a node collection recursive or just a single instance
*
* @param bool $recursive
* @return string
*/
public function renderTree(AbstractTree $tree, $recursive = true)
{
$this->recursionLevel = 0;
$code = '<ul class="level' . $this->recursionLevel . '" style="margin-left:10px">';
// @todo: this doc block is a hack, as it needs to be a TreeReprsentationNode
/** @var TreeRepresentationNode $rootNode */
$rootNode = $tree->getRoot();
$code .= $this->renderNode($rootNode, $recursive);
$code .= '</ul>';
return $code;
}
/**
* Renders a tree recursively or just a single instance
*
* @param bool $recursive
* @return string
*/
public function renderNodeCollection(TreeNodeCollection $collection, $recursive = true)
{
$code = '<ul class="level' . $this->recursionLevel . '" style="margin-left:10px">';
foreach ($collection as $node) {
$code .= $this->renderNode($node, $recursive);
}
$code .= '</ul>';
return $code;
}
}
@@ -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'];
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
/*
* 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;
/**
* Sorted Tree Node Collection
*
* Note: This collection works only with integers as offset keys and not
* with much datasets. You have been warned!
*/
class SortedTreeNodeCollection extends TreeNodeCollection
{
/**
* Checks if a specific node is inside the collection
*
* @return bool
*/
public function contains(TreeNode $node)
{
return $this->offsetOf($node) !== -1;
}
/**
* Returns the offset key of given node
*
* @return int
*/
protected function offsetOf(TreeNode $node)
{
return $this->binarySearch($node, 0, $this->count() - 1);
}
/**
* Binary search that returns the offset of a given node
*
* @param int $start
* @param int $end
* @return int
*/
protected function binarySearch(TreeNode $node, $start, $end)
{
if (!$start && $end - $start >= 2 || $end - $start > 2) {
$divider = (int)ceil(($end - $start) / 2);
if ($this->offsetGet($divider)->equals($node)) {
return $divider;
}
if ($this->offsetGet($divider)->compareTo($node) > 0) {
return $this->binarySearch($node, $start, $divider - 1);
}
return $this->binarySearch($node, $divider + 1, $end);
}
if ($this->offsetGet($start)->equals($node)) {
return $start;
}
if ($this->offsetGet($end)->equals($node)) {
return $end;
}
return -1;
}
/**
* Normalizes the array by reordering the keys
*/
protected function normalize()
{
$nodes = [];
foreach ($this as $node) {
$nodes[] = $node;
}
$this->exchangeArray($nodes);
}
/**
* Adds a node to the internal list in a sorted approach
*
* @param TreeNode $node
*/
public function append($node): void
{
parent::append($node);
$this->asort();
$this->normalize();
}
}
+252
View File
@@ -0,0 +1,252 @@
<?php
/*
* 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;
use TYPO3\CMS\Core\Exception;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Tree Node
*/
class TreeNode implements ComparableNodeInterface
{
/**
* Node Identifier
*
* @var string|int
*/
protected $id = '';
/**
* Parent Node
*
* @var TreeNode|null
*/
protected $parentNode;
/**
* Child Nodes
*
* @var TreeNodeCollection|null
*/
protected $childNodes;
/**
* @internal This is part of the category tree performance hack.
*/
protected array $additionalData = [];
/**
* Constructor
*
* You can move an initial data array to initialize the instance and further objects.
* This is useful for the deserialization.
*
* @param array $data
*/
public function __construct(array $data = [])
{
if ($data !== []) {
$this->dataFromArray($data);
}
}
/**
* Sets the child nodes collection
*/
public function setChildNodes(TreeNodeCollection $childNodes)
{
$this->childNodes = $childNodes;
}
/**
* Removes child nodes collection
*/
public function removeChildNodes()
{
if ($this->childNodes !== null) {
$this->childNodes = null;
}
}
/**
* Returns child nodes collection
*
* @return TreeNodeCollection
*/
public function getChildNodes()
{
return $this->childNodes;
}
/**
* Returns TRUE if the node has child nodes attached
*
* @return bool
*/
public function hasChildNodes()
{
if ($this->childNodes !== null) {
return true;
}
return false;
}
/**
* Sets the identifier
*
* @param string|int $id
*/
public function setId($id)
{
$this->id = $id;
}
/**
* Returns the identifier
*
* @return string|int
*/
public function getId()
{
return $this->id;
}
/**
* Sets the parent node
*
* @param TreeNode|null $parentNode
*/
public function setParentNode(?TreeNode $parentNode = null)
{
$this->parentNode = $parentNode;
}
/**
* Returns the parent node
*
* @return TreeNode
*/
public function getParentNode()
{
return $this->parentNode;
}
/**
* Compares a node if it's identical to another node by the id property.
*
* @return bool
*/
public function equals(TreeNode $other)
{
return $this->id == $other->getId();
}
/**
* Compares a node to another one.
*
* Returns:
* 1 if its greater than the other one
* -1 if its smaller than the other one
* 0 if its equal
*
* @param TreeNode $other
* @return int See description above
*/
public function compareTo($other)
{
if ($this->equals($other)) {
return 0;
}
return $this->id > $other->getId() ? 1 : -1;
}
/**
* @internal This is part of the category tree performance hack
*/
public function getAdditionalData(): array
{
return $this->additionalData;
}
/**
* Returns the node in an array representation that can be used for serialization
*
* @param bool $addChildNodes
* @return array
*/
public function toArray($addChildNodes = true)
{
$arrayRepresentation = [
'serializeClassName' => static::class,
'id' => $this->id,
];
if ($this->parentNode !== null) {
$arrayRepresentation['parentNode'] = $this->parentNode->toArray(false);
} else {
$arrayRepresentation['parentNode'] = '';
}
if ($this->hasChildNodes() && $addChildNodes) {
$arrayRepresentation['childNodes'] = $this->childNodes->toArray();
} else {
$arrayRepresentation['childNodes'] = '';
}
return $arrayRepresentation;
}
/**
* Sets data of the node by a given data array
*
* @param array $data
*/
public function dataFromArray($data)
{
$this->setId($data['id'] ?? $data['uid']);
if (isset($data['parentNode']) && $data['parentNode'] !== '') {
/** @var TreeNode $parentNode */
$parentNode = GeneralUtility::makeInstance($data['parentNode']['serializeClassName'], $data['parentNode']);
$this->setParentNode($parentNode);
}
if (isset($data['childNodes']) && $data['childNodes'] !== '') {
/** @var TreeNodeCollection $childNodes */
$childNodes = GeneralUtility::makeInstance($data['childNodes']['serializeClassName'], $data['childNodes']);
$this->setChildNodes($childNodes);
}
// @todo: This is part of the category tree performance hack
$this->additionalData = $data;
}
/**
* Returns class state to be serialized.
*/
public function __serialize(): array
{
return $this->toArray();
}
/**
* Fills the current node with the given serialized information
*
* @throws Exception if the deserialized object type is not identical to the current one
*/
public function __unserialize(array $arrayRepresentation): void
{
if ($arrayRepresentation['serializeClassName'] !== static::class) {
throw new Exception('Deserialized object type is not identical!', 1294586646);
}
$this->dataFromArray($arrayRepresentation);
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
/*
* 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;
use TYPO3\CMS\Core\Exception;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Tree Node Collection
*/
class TreeNodeCollection extends \ArrayObject
{
/**
* You can move an initial data array to initialize the instance and further objects.
* This is useful for the deserialization.
*
* @param array $data
*/
public function __construct(array $data = [])
{
parent::__construct();
if (!empty($data)) {
$this->dataFromArray($data);
}
}
/**
* Sorts the internal nodes array
*
* @param int $flags Optional parameter, ignored. Added to be compatible with asort method signature in PHP 8.
*/
public function asort(int $flags = SORT_REGULAR): true
{
$this->uasort($this->nodeCompare(...));
return true;
}
/**
* Compares a node with another one
*
* @see \TYPO3\CMS\Backend\Tree\TreeNode::compareTo
* @internal
*/
public function nodeCompare(TreeNode $node, TreeNode $otherNode): int
{
return $node->compareTo($otherNode);
}
/**
* Returns class state to be serialized.
*/
public function __serialize(): array
{
return $this->toArray();
}
/**
* Fills the current node with the given serialized information
*
* @throws Exception if the deserialized object type is not identical to the current one
*/
public function __unserialize($data): void
{
if ($data['serializeClassName'] !== static::class) {
throw new Exception('Deserialized object type is not identical!', 1294586647);
}
$this->dataFromArray($data);
}
/**
* Returns the collection in an array representation for e.g. serialization
*/
public function toArray(): array
{
$arrayRepresentation = [
'serializeClassName' => static::class,
];
$iterator = $this->getIterator();
while ($iterator->valid()) {
$arrayRepresentation[] = $iterator->current()->toArray();
$iterator->next();
}
return $arrayRepresentation;
}
/**
* Sets the data of the node collection by a given array
*/
public function dataFromArray(array $data): void
{
unset($data['serializeClassName']);
foreach ($data as $index => $nodeArray) {
$node = GeneralUtility::makeInstance($nodeArray['serializeClassName'], $nodeArray);
$this->offsetSet($index, $node);
}
}
}
+177
View File
@@ -0,0 +1,177 @@
<?php
/*
* 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;
use TYPO3\CMS\Core\Imaging\Icon;
/**
* Representation Tree Node
*/
class TreeRepresentationNode extends TreeNode
{
/**
* Node Label
*
* @var string
*/
protected $label = '';
/**
* Node Type
*
* @var string
*/
protected $type = '';
/**
* Node CSS Class
*
* @var string
*/
protected $class = '';
/**
* Node Icon
*
* @var string|Icon
*/
protected $icon = '';
/**
* Callback function that is called e.g after a click on the label
*
* @var string
*/
protected $callbackAction = '';
/**
* @param string $class
*/
public function setClass($class)
{
$this->class = $class;
}
/**
* @return string
*/
public function getClass()
{
return $this->class;
}
/**
* @param string|Icon $icon
*/
public function setIcon($icon)
{
$this->icon = $icon;
}
/**
* @return string|Icon
*/
public function getIcon()
{
return $this->icon;
}
/**
* @param string $label
*/
public function setLabel($label)
{
$this->label = $label;
}
/**
* @return string
*/
public function getLabel()
{
return $this->label;
}
/**
* @param string $type
*/
public function setType($type)
{
$this->type = $type;
}
/**
* @return string
*/
public function getType()
{
return $this->type;
}
/**
* Sets the callback action
*
* @param string $callbackAction
*/
public function setCallbackAction($callbackAction)
{
$this->callbackAction = $callbackAction;
}
/**
* Returns the callback action
*
* @return string
*/
public function getCallbackAction()
{
return $this->callbackAction;
}
/**
* Returns the node in an array representation that can be used for serialization
*
* @param bool $addChildNodes
* @return array
*/
public function toArray($addChildNodes = true)
{
$arrayRepresentation = parent::toArray();
$arrayRepresentation = array_merge($arrayRepresentation, [
'label' => $this->label,
'type' => $this->type,
'class' => $this->class,
'icon' => $this->icon,
'callbackAction' => $this->callbackAction,
]);
return $arrayRepresentation;
}
/**
* Sets data of the node by a given data array
*
* @param array $data
*/
public function dataFromArray($data)
{
parent::dataFromArray($data);
$this->setLabel($data['label']);
$this->setType($data['type']);
$this->setClass($data['class']);
$this->setIcon($data['icon']);
$this->setCallbackAction($data['callbackAction']);
}
}
@@ -0,0 +1,323 @@
<?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\View;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Backend\View\BackendLayoutView;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\EndTimeRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\HiddenRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\StartTimeRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* @internal This class is a TYPO3 Backend implementation and is not considered part of the Public TYPO3 API.
*/
abstract class AbstractContentPagePositionMap
{
/**
* Can be set to the language id to select content elements for.
*/
public int $cur_sys_language = 0;
protected BackendLayoutView $backendLayoutView;
public function __construct(BackendLayoutView $backendLayoutView)
{
$this->backendLayoutView = $backendLayoutView;
}
/**
* Creates a linked position icon
*
* @param array|null $row The record row. If this is an array the link will cause an insert after this
* content element, otherwise the link will insert at the first position in the column.
* @param int $colPos Column position value.
* @param int $pid PID value.
* @return string HTML
*/
abstract protected function insertPositionIcon(?array $row, int $colPos, int $pid): string;
/**
* Create content element header (includes record type (CType) icon, content element title, etc.)
*
* @param array $row The element row
* @return string HTML
*/
abstract protected function getRecordHeader(array $row): string;
/**
* Creates HTML for inserting/moving content elements.
*
* @param int $pid page id onto which to insert content element.
* @return string HTML
*/
public function printContentElementColumns(int $pid, array $pageInfo, ServerRequestInterface $request): string
{
$lines = [];
$columnsConfiguration = $this->getColumnsConfiguration($pid);
foreach ($columnsConfiguration as $columnConfiguration) {
if ($columnConfiguration['isRestricted']) {
// Do not fetch records of restricted columns
continue;
}
$colPos = $columnConfiguration['colPos'];
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tt_content');
$queryBuilder
->getRestrictions()
->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->getBackendUser()->workspace))
->removeByType(HiddenRestriction::class)
->removeByType(StartTimeRestriction::class)
->removeByType(EndTimeRestriction::class);
$queryBuilder
->select('*')
->from('tt_content')
->where(
$queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pid, Connection::PARAM_INT)),
$queryBuilder->expr()->eq('colPos', $queryBuilder->createNamedParameter($colPos, Connection::PARAM_INT))
)
->orderBy('sorting');
$queryBuilder->andWhere(
$queryBuilder->expr()->eq(
'sys_language_uid',
$queryBuilder->createNamedParameter($this->cur_sys_language, Connection::PARAM_INT)
)
);
$res = $queryBuilder->executeQuery();
$lines[$colPos] = [
$this->insertPositionIcon(null, $colPos, $pid),
];
while ($row = $res->fetchAssociative()) {
BackendUtility::workspaceOL('tt_content', $row);
if (is_array($row)) {
$lines[$colPos][] = $this->getRecordHeader($row);
$lines[$colPos][] = $this->insertPositionIcon($row, $colPos, $pid);
}
}
}
return $this->printRecordMap($lines, $columnsConfiguration, $pid);
}
/**
* Creates the table with the content columns
*
* @param array $lines Array with arrays of lines for each column
* @param array $tcaColumnsConfiguration Column configuration array
* @param int $pid The id of the page
* @return string HTML
*/
protected function printRecordMap(array $lines, array $tcaColumnsConfiguration, int $pid): string
{
$lang = $this->getLanguageService();
$hideRestrictedColumns = (bool)(BackendUtility::getPagesTSconfig($pid)['mod.']['web_layout.']['hideRestrictedCols'] ?? false);
$backendLayout = $this->backendLayoutView->getSelectedBackendLayout($pid);
if (isset($backendLayout['__config']['backend_layout.'])) {
// Build position map based on the fetched backend layout
$colCount = (int)($backendLayout['__config']['backend_layout.']['colCount'] ?? 0);
$rowCount = (int)($backendLayout['__config']['backend_layout.']['rowCount'] ?? 0);
// Cycle through rows
$tableRows = [];
for ($row = 1; $row <= $rowCount; $row++) {
$rowConfig = $backendLayout['__config']['backend_layout.']['rows.'][$row . '.'] ?? null;
if (!$rowConfig) {
// Skip empty rows
continue;
}
// Cycle through cells
$tableCells = [];
for ($col = 1; $col <= $colCount; $col++) {
$columnConfig = $rowConfig['columns.'][$col . '.'] ?? null;
if (!$columnConfig) {
// Skip empty columns
continue;
}
// Set table cell attributes
$tableCellAttributes = [
'class' => 'col-nowrap col-min',
];
if (isset($columnConfig['colspan'])) {
$tableCellAttributes['colspan'] = $columnConfig['colspan'];
}
if (isset($columnConfig['rowspan'])) {
$tableCellAttributes['rowspan'] = $columnConfig['rowspan'];
}
$columnKey = null;
$columnTitle = '';
$isRestricted = false;
$isUnassigned = true;
if (isset($columnConfig['colPos'])) {
// If colPos is defined, initialize column information (e.g. title and restricted state)
$columnKey = (int)$columnConfig['colPos'];
foreach ($tcaColumnsConfiguration as $tcaColumnConfiguration) {
if ($tcaColumnConfiguration['colPos'] === $columnKey) {
$columnTitle = '<strong>' . htmlspecialchars($lang->sL($tcaColumnConfiguration['title'])) . '</strong>';
$isRestricted = $tcaColumnConfiguration['isRestricted'];
$isUnassigned = false;
}
}
}
// Generate the cell content, based on the columns' state (e.g. restricted or unassigned)
$cellContent = '';
if ($isRestricted) {
if ($hideRestrictedColumns) {
// Hide in case this column is not accessible and hideRestrictedColumns is set
$tableCellAttributes['class'] = 'hidden';
} else {
$cellContent = '
<p class="column-title">
' . $columnTitle . ' <em>(' . htmlspecialchars($lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_layout.xlf:noAccess')) . ')</em>
</p>';
$tableCellAttributes['class'] .= ' danger';
}
} elseif ($isUnassigned) {
if ($hideRestrictedColumns) {
// Hide in case this column is not assigned and hideRestrictedColumns is set
$tableCellAttributes['class'] = 'hidden';
} else {
$cellContent = '
<em>
' . htmlspecialchars($lang->sL($columnConfig['name']) ?: '') . '
' . ' (' . htmlspecialchars($lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_layout.xlf:notAssigned')) . ')' . '
</em>';
$tableCellAttributes['class'] .= ' warning';
}
} else {
// If not restricted and not unassigned, wrap column title and render list (if available)
$cellContent = '<p class="column-title">' . $columnTitle . '</p>';
if (!empty($lines[$columnKey])) {
$cellContent .= '
<ul>
' . implode(LF, array_map(static fn(string $line): string => '<li>' . $line . '</li>', $lines[$columnKey])) . '
</ul>';
}
}
// Add the table cell
$tableCells[] = '<td ' . GeneralUtility::implodeAttributes($tableCellAttributes) . '>' . $cellContent . '</td>';
}
// Add the table row
$tableRows[] = '<tr>' . implode(LF, $tableCells) . '</tr>';
}
// Create the table content
$tableContent
= '<colgroup>' . str_repeat('<col span="1" style="width: calc(100% / ' . $colCount . ')">', $colCount) . '</colgroup>'
. '<tbody>' . implode(LF, $tableRows) . '</tbody>';
} else {
// Build position map based on TCA colPos configuration
$tableCells = [];
foreach ($tcaColumnsConfiguration as $tcaColumnConfiguration) {
if ($hideRestrictedColumns && $tcaColumnConfiguration['isRestricted']) {
// Skip in case this column is not accessible and restricted columns should be hidden
continue;
}
// Generate the cell content, based on the columns' state (e.g. restricted or unassigned)
$tableCellClasses = 'col-nowrap col-min';
$columnTitle = '<strong>' . htmlspecialchars($tcaColumnConfiguration['title']) . '</strong>';
if ($tcaColumnConfiguration['isRestricted']) {
// If this colPos is restricted, add an information to the column title and color the cell
$tableCellClasses .= ' danger';
$cellContent = '
<p class="column-title">
' . $columnTitle . ' <em>(' . htmlspecialchars($lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_layout.xlf:noAccess')) . ')</em>
</p>';
} else {
// If not restricted, wrap column title and render list (if available)
$cellContent = '<p class="column-title">' . $columnTitle . '</p>';
if (!empty($lines[$tcaColumnConfiguration['colPos']])) {
$cellContent .= '
<ul>
' . implode(LF, array_map(static fn(string $line): string => '<li>' . $line . '</li>', $lines[$tcaColumnConfiguration['colPos']])) . '
</ul>';
}
}
// Add the table cell
$tableCells[] = '<td class="' . $tableCellClasses . '">' . $cellContent . '</td>';
}
// Create the table content
$tableContent = '<tbody><tr>' . implode(LF, $tableCells) . '</tr></tbody>';
}
// Return the record map (table)
return '
<table class="page-position-grid">
' . $tableContent . '
</table>';
}
/**
* Fetch TCA colPos list from BackendLayoutView and prepare for map generation.
* This also takes the "colPos_list" TSconfig into account.
*/
protected function getColumnsConfiguration(int $pageId): array
{
$backendLayout = $this->backendLayoutView->getBackendLayoutForPage($pageId);
$items = [];
// Prepare the columns configuration (using named keys, etc.)
foreach ($backendLayout->getUsedColumns() as $colPos => $label) {
$items[] = [
'title' => $label,
'colPos' => (int)$colPos,
'isRestricted' => false,
];
}
$sharedColPosList = trim(BackendUtility::getPagesTSconfig($pageId)['mod.']['SHARED.']['colPos_list'] ?? '');
if ($sharedColPosList !== '') {
$activeColPosArray = array_unique(GeneralUtility::intExplode(',', $sharedColPosList));
if (!empty($items) && !empty($activeColPosArray)) {
foreach ($items as &$item) {
if (!in_array((int)$item['colPos'], $activeColPosArray, true)) {
$item['isRestricted'] = true;
}
}
unset($item);
}
}
return $items;
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
+419
View File
@@ -0,0 +1,419 @@
<?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\View;
use Doctrine\DBAL\Result;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
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\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconProvider\AbstractSvgIconProvider;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Base class for creating a browsable array/page/folder tree in HTML
*
* @internal This class is a TYPO3 Backend implementation and is not considered part of the Public TYPO3 API.
*/
abstract class AbstractTreeView
{
/**
* Database table to get the tree data from.
* Leave blank if data comes from an array.
*/
protected string $table = 'pages';
/**
* Defines the field of $table which is the parent id field (like pid for table pages).
*/
protected string $parentField = 'pid';
/**
* WHERE clause used for selecting records for the tree. Is set by function init.
*
* @see init()
*/
protected string $clause = '';
/**
* Field for ORDER BY. Is set by function init.
*
* @see init()
*/
public string $orderByFields = 'sorting';
/**
* Default set of fields selected from the tree table.
* Make SURE that these fields names listed herein are actually possible to select from $this->table (if that variable is set to a TCA table name)
*
* @see addField()
*/
protected array $fieldArray = [
'uid',
'pid',
'title',
'is_siteroot',
'doktype',
'nav_title',
'mount_pid',
'php_tree_stop',
't3ver_state',
'hidden',
'starttime',
'endtime',
'fe_group',
'module',
'extendToSubpages',
'nav_hide',
't3ver_wsid',
'crdate',
'tstamp',
'sorting',
'deleted',
'perms_userid',
'perms_groupid',
'perms_user',
'perms_group',
'perms_everybody',
'editlock',
'l18n_cfg',
];
/**
* If true, HTML code is also accumulated in ->tree array during rendering of the tree
*/
public bool $makeHTML = true;
// *********
// Internal
// *********
// For record trees:
// one-dim array of the uid's selected.
protected array $ids = [];
// The hierarchy of element uids
protected array $ids_hierarchy = [];
// The hierarchy of versioned element uids
public array $orig_ids_hierarchy = [];
// Temporary, internal array
public array $buffer_idH = [];
// For both types
// Tree is accumulated in this variable
public array $tree = [];
/**
* @param string $clause Record WHERE clause
* @param string $orderByFields Record ORDER BY field
*/
public function init($clause = '', $orderByFields = '')
{
if ($clause) {
$this->clause = $clause;
}
if ($orderByFields) {
$this->orderByFields = $orderByFields;
}
}
public function addField(string $field): void
{
$this->fieldArray[] = $field;
}
/**
* Resets the tree, recs, ids, ids_hierarchy and orig_ids_hierarchy internal variables. Use it if you need it.
*/
protected function reset(): void
{
$this->tree = [];
$this->ids = [];
$this->ids_hierarchy = [];
$this->orig_ids_hierarchy = [];
}
/*******************************************
*
* rendering parts
*
*******************************************/
/**
* Generate the plus/minus icon for the browsable tree.
*
* @param array $row Record for the entry
* @param int $a The current entry number
* @param int $c The total number of entries. If equal to $a, a "bottom" element is returned.
* @param int $nextCount The number of sub-elements to the current element.
* @param bool $isOpen The element was expanded to render subelements if this flag is set.
* @return string Image tag with the plus/minus icon.
* @see \TYPO3\CMS\Backend\Tree\View\PageTreeView::PMicon()
*/
protected function PMicon($row, $a, $c, $nextCount, $isOpen)
{
if ($nextCount) {
$iconFactory = GeneralUtility::makeInstance(IconFactory::class);
// Wrap the plus/minus icon in a link
$anchor = $row['uid'] ? '#' . $row['uid'] : '';
$name = $row['uid'] ? ' name="' . $row['uid'] . '"' : '';
$aUrl = $anchor;
if ($isOpen) {
$class = 'treelist-control-open';
$icon = $iconFactory->getIcon('actions-chevron-down', IconSize::SMALL);
} else {
$class = 'treelist-control-collapsed';
$icon = $iconFactory->getIcon('actions-chevron-end', IconSize::SMALL);
}
return '<a class="treelist-control ' . $class . '" href="' . htmlspecialchars($aUrl) . '"' . $name . '>' . $icon->render(AbstractSvgIconProvider::MARKUP_IDENTIFIER_INLINE) . '</a>';
}
return '';
}
/*******************************************
*
* tree handling
*
*******************************************/
/**
* Returns TRUE/FALSE if the next level for $id should be expanded - based on
* data in $this->stored[][] and ->expandAll flag.
* Used in subclasses
*
* @param int $id Record id/key
* @return bool
* @internal
* @see \TYPO3\CMS\Backend\Tree\View\PageTreeView::expandNext()
*/
public function expandNext($id)
{
return false;
}
/******************************
*
* Functions that might be overwritten by extended classes
*
********************************/
/**
* Get the icon markup for the row
*
* @param array $row The row to get the icon for
* @return string The icon markup, wrapped into a span tag, with the records title as title attribute
*/
protected function getIcon(array $row): string
{
$title = $this->getTitleAttrib($row);
$iconFactory = GeneralUtility::makeInstance(IconFactory::class);
$icon = $row['is_siteroot'] ? $iconFactory->getIcon('apps-pagetree-folder-root', IconSize::SMALL) : $iconFactory->getIconForRecord($this->table, $row, IconSize::SMALL);
return $icon->setTitle($title)->render();
}
/**
* Returns the value for the image "title" attribute
*
* @param array $row The input row array (where the key "title" is used for the title)
* @return string The attribute value (is htmlspecialchared() already)
*/
protected function getTitleAttrib($row)
{
return htmlspecialchars($row['title']);
}
/********************************
*
* tree data building
*
********************************/
/**
* Fetches the data for the tree
*
* @param int $uid item id for which to select subitems (parent id)
* @param int $depth Max depth (recursivity limit)
* @param string $depthData HTML-code prefix for recursive calls.
* @return int<0, max> The count of items on the level
*/
public function getTree(int $uid, int $depth = 999, string $depthData = ''): int
{
// Buffer for id hierarchy is reset:
$this->buffer_idH = [];
// Init vars
$HTML = '';
$a = 0;
$res = $this->getDataInit($uid);
$c = $res->rowCount();
$crazyRecursionLimiter = 9999;
$idH = [];
// Traverse the records:
while ($crazyRecursionLimiter > 0 && ($row = $this->getDataNext($res))) {
if (!$this->getBackendUser()->isInWebMount($this->table === 'pages' ? $row : $row['pid'])) {
// Current record is not within web mount => skip it
continue;
}
$a++;
$crazyRecursionLimiter--;
$newID = $row['uid'];
if ($newID == 0) {
throw new \RuntimeException('Endless recursion detected: TYPO3 has detected an error in the database. Please fix it manually (e.g. using phpMyAdmin) and change the UID of ' . $this->table . ':0 to a new value. See https://forge.typo3.org/issues/16150 to get more information about a possible cause.', 1294586383);
}
// Reserve space.
$this->tree[] = [];
end($this->tree);
// Get the key for this space
$treeKey = key($this->tree);
// Accumulate the id of the element in the internal arrays
$this->ids[] = ($idH[$row['uid']]['uid'] = $row['uid']);
$this->ids_hierarchy[$depth][] = $row['uid'];
$this->orig_ids_hierarchy[$depth][] = (!empty($row['_ORIG_uid'])) ? $row['_ORIG_uid'] : $row['uid'];
// Make a recursive call to the next level
$nextLevelDepthData = $depthData . '<span class="treeline-icon treeline-icon-' . ($a === $c ? 'clear' : 'line') . '"></span>';
$hasSub = $this->expandNext($newID) && !($row['php_tree_stop'] ?? false);
if ($depth > 1 && $hasSub) {
$nextCount = $this->getTree($newID, $depth - 1, $nextLevelDepthData);
if (!empty($this->buffer_idH)) {
$idH[$row['uid']]['subrow'] = $this->buffer_idH;
}
// Set "did expand" flag
$isOpen = true;
} else {
$nextCount = $this->getCount((int)$newID);
// Clear "did expand" flag
$isOpen = false;
}
// Set HTML-icons, if any:
if ($this->makeHTML) {
$HTML = $this->PMicon($row, $a, $c, $nextCount, $isOpen);
}
// Finally, add the row/HTML content to the ->tree array in the reserved key.
$this->tree[$treeKey] = [
'row' => $row,
'HTML' => $HTML,
'icon' => $this->getIcon($row),
'invertedDepth' => $depth,
'depthData' => $depthData,
'hasSub' => $nextCount && $hasSub,
'isFirst' => $a === 1,
'isLast' => $a === $c,
];
}
$res->free();
$this->buffer_idH = $idH;
return $c;
}
/********************************
*
* Data handling
* Works with records and arrays
*
********************************/
/**
* Returns the number of records having the parent id, $uid
*
* @param int $uid Id to count subitems for
*/
protected function getCount(int $uid): int
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($this->table);
$queryBuilder->getRestrictions()
->removeAll()
->add(GeneralUtility::makeInstance(DeletedRestriction::class))
->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->getBackendUser()->workspace));
$count = $queryBuilder
->count('uid')
->from($this->table)
->where(
$queryBuilder->expr()->eq(
$this->parentField,
$queryBuilder->createNamedParameter($uid, Connection::PARAM_INT)
),
QueryHelper::stripLogicalOperatorPrefix($this->clause)
)
->executeQuery()
->fetchOne();
return (int)$count;
}
/**
* Getting the tree data: Selecting/Initializing data pointer to items for a certain parent id.
* For tables: This will make a database query to select all children to "parent"
*/
protected function getDataInit(int $parentId): Result
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($this->table);
$queryBuilder->getRestrictions()
->removeAll()
->add(GeneralUtility::makeInstance(DeletedRestriction::class))
->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->getBackendUser()->workspace));
$queryBuilder
->select(...$this->fieldArray)
->from($this->table)
->where(
$queryBuilder->expr()->eq(
$this->parentField,
$queryBuilder->createNamedParameter($parentId, Connection::PARAM_INT)
),
QueryHelper::stripLogicalOperatorPrefix($this->clause)
);
foreach (QueryHelper::parseOrderBy($this->orderByFields) as $orderPair) {
[$fieldName, $order] = $orderPair;
$queryBuilder->addOrderBy($fieldName, $order);
}
return $queryBuilder->executeQuery();
}
/**
* Getting the tree data: next entry
*
* @see getDataInit()
*/
protected function getDataNext(Result $res): array|false
{
while ($row = $res->fetchAssociative()) {
BackendUtility::workspaceOL($this->table, $row, $this->getBackendUser()->workspace, true);
if (is_array($row)) {
break;
}
}
return $row;
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,119 @@
<?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\View;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Backend\View\BackendLayoutView;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Utility\StringUtility;
/**
* Position map class for creating content elements
*
* @internal This class is a TYPO3 Backend implementation and is not considered part of the Public TYPO3 API.
*/
#[Autoconfigure(public: true)]
class ContentCreationPagePositionMap extends AbstractContentPagePositionMap
{
/**
* Default values defined for the item
*/
public array $defVals = [];
/**
* Whether the item should directly be persisted (avoiding FormEngine)
*/
public bool $saveAndClose = false;
/**
* The return url, forwarded to FormEngine (or SimpleDataHandler)
*/
public string $R_URI = '';
protected IconFactory $iconFactory;
protected UriBuilder $uriBuilder;
public function __construct(IconFactory $iconFactory, UriBuilder $uriBuilder, BackendLayoutView $backendLayoutView)
{
$this->iconFactory = $iconFactory;
$this->uriBuilder = $uriBuilder;
parent::__construct($backendLayoutView);
}
/**
* {@inheritdoc}
*/
protected function insertPositionIcon(?array $row, int $colPos, int $pid): string
{
if ($this->saveAndClose) {
$target = (string)$this->uriBuilder->buildUriFromRoute('tce_db', [
'data' => [
'tt_content' => [
StringUtility::getUniqueId('NEW') => array_replace($this->defVals, [
'colPos' => $colPos,
'pid' => (is_array($row) ? -$row['uid'] : $pid),
'sys_language_uid' => $this->cur_sys_language,
]),
],
],
'redirect' => $this->R_URI,
]);
} else {
// @todo pass module context to this handler and pass to record_edit here
$target = (string)$this->uriBuilder->buildUriFromRoute('record_edit', [
'edit' => [
'tt_content' => [
(is_array($row) ? -$row['uid'] : $pid) => 'new',
],
],
'returnUrl' => $this->R_URI,
'defVals' => [
'tt_content' => array_replace($this->defVals, [
'colPos' => $colPos,
'sys_language_uid' => $this->cur_sys_language,
]),
],
]);
}
$buttonLabel = htmlspecialchars($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_misc.xlf:insertNewRecordHere'));
return '
<div class="page-position-action">
<button type="button" class="btn btn-default btn-sm" data-target="' . htmlspecialchars($target) . '" title="' . $buttonLabel . '">
' . $this->iconFactory->getIcon('actions-arrow-left-alt', IconSize::SMALL)->render() . ' ' . $buttonLabel . '
</button>
</div>';
}
/**
* {@inheritdoc}
*/
protected function getRecordHeader(array $row): string
{
return '
<div class="page-position-record">
<span title="' . BackendUtility::getRecordIconAltText($row, 'tt_content') . '">
' . $this->iconFactory->getIconForRecord('tt_content', $row, IconSize::SMALL)->render() . '
' . BackendUtility::getRecordTitle('tt_content', $row, true) . '
</span>
</div>';
}
}
@@ -0,0 +1,99 @@
<?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\View;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Backend\View\BackendLayoutView;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Position map class for moving content elements
*
* @internal This class is a TYPO3 Backend implementation and is not considered part of the Public TYPO3 API.
*/
#[Autoconfigure(public: true)]
class ContentMovingPagePositionMap extends AbstractContentPagePositionMap
{
/**
* The move uid
*/
public int $moveUid = 0;
/**
* The copy mode (either "move" or "copy")
*/
public string $copyMode = 'move';
protected IconFactory $iconFactory;
public function __construct(IconFactory $iconFactory, BackendLayoutView $backendLayoutView)
{
$this->iconFactory = $iconFactory;
parent::__construct($backendLayoutView);
}
/**
* {@inheritdoc}
*/
protected function insertPositionIcon(?array $row, int $colPos, int $pid): string
{
if (is_array($row)) {
$attributes = [
'data-action' => 'paste',
'data-position' => '-' . $row['uid'],
'data-colpos' => $colPos,
];
} else {
$attributes = [
'data-action' => 'paste',
'data-position' => $pid,
'data-colpos' => $colPos,
];
}
$buttonLabelTransUnit = $this->copyMode === 'move' ? 'moveElementToHere' : 'copyElementToHere';
$buttonLabel = htmlspecialchars($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_misc.xlf:' . $buttonLabelTransUnit));
return '
<div class="page-position-action">
<button class="btn btn-default" title="' . $buttonLabel . '" ' . GeneralUtility::implodeAttributes($attributes, true) . '>
' . $this->iconFactory->getIcon('actions-arrow-left-alt', IconSize::SMALL)->render() . ' <span class="t3js-button-label">' . $buttonLabel . '</span>
</button>
</div>';
}
/**
* Create record header (includes the record icon, record title etc.)
*
* @param array $row Record row.
* @return string HTML
*/
protected function getRecordHeader(array $row): string
{
return '
<div class="page-position-record">
<span title="' . BackendUtility::getRecordIconAltText($row, 'tt_content') . '">
' . $this->iconFactory->getIconForRecord('tt_content', $row, IconSize::SMALL)->render() . '
' . ($this->moveUid === (int)$row['uid'] ? '<strong>' : '') . '
' . BackendUtility::getRecordTitle('tt_content', $row, true) . '
' . ($this->moveUid === (int)$row['uid'] ? '</strong>' : '') . '
</span>
</div>';
}
}
@@ -0,0 +1,29 @@
<?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\View;
interface LinkParameterProviderInterface
{
/**
* Provides an array or GET parameters for URL generation
*
* @param array $values Array of values to include into the parameters or which might influence the parameters
* @return string[] Array of parameters which have to be added to URLs
*/
public function getUrlParameters(array $values): array;
}
+78
View File
@@ -0,0 +1,78 @@
<?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\View;
/**
* Generate a page-tree, non-browsable.
*
* @internal This class is a TYPO3 Backend implementation and is not considered part of the Public TYPO3 API.
*/
class PageTreeView extends AbstractTreeView
{
protected ?int $currentPageId = null;
/**
* Init function
* REMEMBER to feed a $clause which will filter out non-readable pages!
*
* @param string $clause Part of where query which will filter out non-readable pages.
* @param string $orderByFields Record ORDER BY field
*/
public function init($clause = '', $orderByFields = '')
{
$tag = \Local\Multilanguage\Service\DefaultLanguageTagService::getTag();
parent::init(" AND deleted=0 AND (language_tag='" . $tag . "' OR language_tag='') " . $clause, $orderByFields);
}
/**
* Returns TRUE/FALSE if the next level for $id should be expanded - and all levels should, so we always return true.
* Here the branch is expanded if the current id matches the global id for the listing/new
*
* @param int $id ID (uid) to test for
* @return bool
*/
public function expandNext($id)
{
if ($this->currentPageId !== null) {
return (int)$id === $this->currentPageId;
}
return true;
}
/**
* Generate the plus/minus icon for the browsable tree.
* In this case, there is no plus-minus icon displayed.
*
* @param array $row Record for the entry
* @param int $a The current entry number
* @param int $c The total number of entries. If equal to $a, a 'bottom' element is returned.
* @param int $nextCount The number of sub-elements to the current element.
* @param bool $isOpen The element was expanded to render subelements if this flag is set.
* @return string Image tag with the plus/minus icon.
* @see AbstractTreeView::PMicon()
*/
protected function PMicon($row, $a, $c, $nextCount, $isOpen)
{
return '<span class="treeline-icon treeline-icon-join' . ($a == $c ? 'bottom' : '') . '"></span>';
}
public function setCurrentPageId(int $currentPageId): void
{
$this->currentPageId = $currentPageId;
}
}