TYPO3 v15 dev-main snapshot ()

This commit is contained in:
2026-08-10 22:31:00 +02:00
commit f9941541b7
1178 changed files with 135377 additions and 0 deletions
@@ -0,0 +1,107 @@
<?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\Search\Event;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Search\LiveSearch\SearchDemand\SearchDemand;
/**
* PSR-14 event to add, change or remove data for the live search form
*/
final class BeforeLiveSearchFormIsBuiltEvent
{
private SearchDemand $searchDemand;
/**
* @var array<non-empty-string, mixed>
*/
private array $additionalViewData = [];
/**
* @param list<non-empty-string> $hints
*/
public function __construct(
private array $hints,
private readonly ServerRequestInterface $request,
) {
$this->searchDemand = SearchDemand::fromRequest($this->request);
}
/**
* @return list<non-empty-string>
*/
public function getHints(): array
{
return $this->hints;
}
/**
* @param list<non-empty-string> $hints
*/
public function setHints(array $hints): void
{
$this->hints = [];
$this->addHints(...$hints);
}
public function addHint(string $label): void
{
$this->addHints($label);
}
public function addHints(string ...$labels): void
{
foreach ($labels as $label) {
if ($label === '') {
continue;
}
$this->hints[] = $label;
}
}
public function getRequest(): ServerRequestInterface
{
return $this->request;
}
public function getSearchDemand(): SearchDemand
{
return $this->searchDemand;
}
public function setSearchDemand(SearchDemand $searchDemand): void
{
$this->searchDemand = $searchDemand;
}
/**
* @return array<non-empty-string, mixed>
*/
public function getAdditionalViewData(): array
{
return $this->additionalViewData;
}
/**
* @param array<non-empty-string, mixed> $viewData
*/
public function setAdditionalViewData(array $viewData): void
{
$this->additionalViewData = $viewData;
}
}
@@ -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\Search\Event;
use TYPO3\CMS\Backend\Search\LiveSearch\SearchDemand\SearchDemand;
/**
* PSR-14 event to modify the incoming input about which tables should be searched for within
* the live search results. This allows adding additional DB tables to be excluded / ignored, to
* further limit the search result on certain page IDs or to modify the search query altogether.
*/
final class BeforeSearchInDatabaseRecordProviderEvent
{
private array $ignoredTables = [];
public function __construct(
private array $searchPageIds,
private SearchDemand $searchDemand
) {}
public function getSearchPageIds(): array
{
return $this->searchPageIds;
}
public function setSearchPageIds(array $searchPageIds): void
{
$this->searchPageIds = $searchPageIds;
}
public function getSearchDemand(): SearchDemand
{
return $this->searchDemand;
}
public function setSearchDemand(SearchDemand $searchDemand): void
{
$this->searchDemand = $searchDemand;
}
public function ignoreTable(string $table): void
{
$this->ignoredTables[] = $table;
}
public function setIgnoredTables(array $tables): void
{
$this->ignoredTables = $tables;
}
public function isTableIgnored(string $table): bool
{
return in_array($table, $this->ignoredTables, true);
}
/**
* @return string[]
*/
public function getIgnoredTables(): array
{
return $this->ignoredTables;
}
}
@@ -0,0 +1,71 @@
<?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\Search\Event;
use TYPO3\CMS\Backend\Search\LiveSearch\SearchDemand\SearchDemand;
use TYPO3\CMS\Core\Database\Query\Expression\CompositeExpression;
/**
* PSR-14 event to modify the query builder instance for the live search
*/
final class ModifyConstraintsForLiveSearchEvent
{
/**
* @param array<int, string|CompositeExpression> $constraints
*/
public function __construct(
private array $constraints,
private readonly string $table,
private readonly SearchDemand $searchDemand
) {}
/**
* @return array<int, string|CompositeExpression>
*/
public function getConstraints(): array
{
return $this->constraints;
}
/**
* Note that we only add a single/multiple constraints
* and do not allow to remove or override existing ones. This is
* a safeguard to not overwrite security-related query constraints.
*/
public function addConstraints(string|CompositeExpression ...$constraints): void
{
foreach ($constraints as $constraint) {
$this->addConstraint($constraint);
}
}
public function addConstraint(string|CompositeExpression $constraint): void
{
$this->constraints[] = $constraint;
}
public function getTableName(): string
{
return $this->table;
}
public function getSearchDemand(): SearchDemand
{
return $this->searchDemand;
}
}
@@ -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\Search\Event;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
/**
* PSR-14 event to modify the query builder instance for the live search
*/
final readonly class ModifyQueryForLiveSearchEvent
{
public function __construct(private QueryBuilder $queryBuilder, private string $table) {}
public function getQueryBuilder(): QueryBuilder
{
return $this->queryBuilder;
}
public function getTableName(): string
{
return $this->table;
}
}
@@ -0,0 +1,33 @@
<?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\Search\Event;
use TYPO3\CMS\Backend\Search\LiveSearch\ResultItem;
/**
* PSR-14 event to modify as result item created by the live search
*/
final readonly class ModifyResultItemInLiveSearchEvent
{
public function __construct(private ResultItem $resultItem) {}
public function getResultItem(): ResultItem
{
return $this->resultItem;
}
}
@@ -0,0 +1,80 @@
<?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\Search\EventListener;
use TYPO3\CMS\Backend\Search\Event\ModifyConstraintsForLiveSearchEvent;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Routing\SiteUrlResolver;
/**
* Event listener to add a "search for live frontend URI" query constraint
*
* @internal
*/
final readonly class AddLiveSearchFrontendUriResolverListener
{
public function __construct(
private ConnectionPool $connectionPool,
private SiteUrlResolver $siteUrlResolver
) {}
/**
* For a similar implementation to the Page Tree filter instead of "Live Search":
* @see \TYPO3\CMS\Backend\Tree\Repository\PageTreeFilter->addUidsFromSearchPhraseWithFrontendUri()
*/
#[AsEventListener('typo3/cms-backend/add-live-search-frontend-uri-resolver')]
public function __invoke(ModifyConstraintsForLiveSearchEvent $event): void
{
if ($event->getTableName() !== 'pages') {
return;
}
$queryString = $event->getSearchDemand()->getQuery();
// Only if a search pattern uses "http(s)://...." then a frontend URL will be resolved.
if (!str_starts_with($queryString, 'http://') && !str_starts_with($queryString, 'https://')) {
return;
}
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
$resolvedPage = $this->siteUrlResolver->resolvePageUidAndLanguageBySiteUrl($queryString);
if ($resolvedPage !== null) {
$event->addConstraint(
$queryBuilder->expr()->or(
$queryBuilder->expr()->eq(
'uid',
$resolvedPage['uid'],
),
// On top of the common default page, finding a result by its URI also attaches
// the specific language version, too.
$queryBuilder->expr()->and(
$queryBuilder->expr()->eq(
'sys_language_uid',
$resolvedPage['languageUid'],
),
$queryBuilder->expr()->eq(
'l10n_parent',
$resolvedPage['uid'],
),
),
),
);
}
}
}
@@ -0,0 +1,82 @@
<?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\Search\EventListener;
use TYPO3\CMS\Backend\Search\Event\ModifyResultItemInLiveSearchEvent;
use TYPO3\CMS\Backend\Search\LiveSearch\DatabaseRecordProvider;
use TYPO3\CMS\Backend\Search\LiveSearch\ResultItem;
use TYPO3\CMS\Backend\Search\LiveSearch\ResultItemAction;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageServiceFactory;
/**
* Event listener to add actions to search results
*
* @internal
*/
final readonly class AddLiveSearchResultActionsListener
{
public function __construct(
private IconFactory $iconFactory,
private LanguageServiceFactory $languageServiceFactory
) {}
#[AsEventListener('typo3/cms-backend/add-live-search-result-actions-listener')]
public function __invoke(ModifyResultItemInLiveSearchEvent $event): void
{
$resultItem = $event->getResultItem();
if ($resultItem->getProviderClassName() !== DatabaseRecordProvider::class) {
return;
}
if (($resultItem->getExtraData()['table'] ?? null) === 'be_users') {
$this->addSwitchUserAction($resultItem);
}
}
private function addSwitchUserAction(ResultItem $resultItem): void
{
$row = $resultItem->getInternalData()['row'];
$backendUserIsActive
= (int)$row['disable'] === 0
&& ($row['starttime'] === 0 && $row['endtime'] === 0 || $row['starttime'] <= time() && ($row['starttime'] === 0 && $row['endtime'] > time()));
$currentUser = $this->getBackendUser();
if (
$backendUserIsActive
&& $currentUser->isAdmin()
&& $currentUser->getOriginalUserIdWhenInSwitchUserMode() === null
&& (int)$currentUser->getUserId() !== (int)$resultItem->getExtraData()['uid']
) {
$languageService = $this->languageServiceFactory->createFromUserPreferences($this->getBackendUser());
$switchUserAction = (new ResultItemAction('switch_backend_user'))
->setLabel($languageService->sL('LLL:EXT:beuser/Resources/Private/Language/locallang.xlf:switchBackMode'))
->setIcon($this->iconFactory->getIcon('actions-system-backend-user-switch', IconSize::SMALL))
->setUrl('#');
$resultItem->addAction($switchUserAction);
}
}
private function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,35 @@
<?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\Search\EventListener;
use TYPO3\CMS\Backend\Search\Event\BeforeSearchInDatabaseRecordProviderEvent;
use TYPO3\CMS\Core\Attribute\AsEventListener;
/**
* Event listener to exclude the "pages" table from the table lookup of the database record provider
*
* @internal
*/
final class ExcludePagesFromSearchFieldsLookup
{
#[AsEventListener('typo3/cms-backend/exclude-pages-from-search-fields-lookup')]
public function __invoke(BeforeSearchInDatabaseRecordProviderEvent $event): void
{
$event->ignoreTable('pages');
}
}
@@ -0,0 +1,111 @@
<?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\Search\LiveSearch;
use TYPO3\CMS\Backend\Module\ModuleInterface;
use TYPO3\CMS\Backend\Module\ModuleProvider;
use TYPO3\CMS\Backend\Routing\Exception\RouteNotFoundException;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Search\LiveSearch\SearchDemand\SearchDemand;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Localization\LanguageServiceFactory;
class BackendModuleProvider implements SearchProviderInterface
{
private LanguageService $languageService;
public function __construct(
private readonly LanguageServiceFactory $languageServiceFactory,
private readonly UriBuilder $uriBuilder,
private readonly IconFactory $iconFactory,
private readonly ModuleProvider $moduleProvider
) {
$this->languageService = $this->languageServiceFactory->createFromUserPreferences($this->getBackendUser());
}
public function getFilterLabel(): string
{
return $this->languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang.xlf:liveSearch.backendModuleProvider.filterLabel');
}
public function find(SearchDemand $searchDemand): array
{
$items = [];
foreach ($this->getFilteredModules($searchDemand) as $module) {
// we can't generate accessible URLs for all modules by their identifier
// if URL generation fails, we don't create an action to open a module
// and if no actions exist, we skip result item creation altogether
try {
$moduleUrl = (string)$this->uriBuilder->buildUriFromRoute($module->getIdentifier());
} catch (RouteNotFoundException) {
continue;
}
$action = (new ResultItemAction('open_module'))
->setLabel($this->languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang.xlf:resultItem.backendModuleProvider.openModule'))
->setUrl($moduleUrl);
$iconIdentifier = $module->getIconIdentifier();
if ($iconIdentifier === '' && $module->hasParentModule()) {
$iconIdentifier = $module->getParentModule()->getIconIdentifier();
}
$items[] = (new ResultItem(self::class))
->setItemTitle($this->languageService->sL($module->getTitle()))
->setTypeLabel($this->languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang.xlf:liveSearch.backendModuleProvider.typeLabel'))
->setIcon($this->iconFactory->getIcon($iconIdentifier, IconSize::SMALL))
->setActions($action)
->setExtraData([
'moduleIdentifier' => $module->getIdentifier(),
]);
}
return $items;
}
public function count(SearchDemand $searchDemand): int
{
return count($this->getFilteredModules($searchDemand));
}
/**
* @return list<ModuleInterface>
*/
private function getFilteredModules(SearchDemand $searchDemand): array
{
$normalizedQuery = mb_strtolower($searchDemand->getQuery());
$filteredModules = array_filter(
$this->moduleProvider->getModules($this->getBackendUser(), true, false),
fn(ModuleInterface $module) => str_contains(mb_strtolower($this->languageService->sL($module->getTitle())), $normalizedQuery)
);
$firstResult = $searchDemand->getOffset();
$remainingItems = $searchDemand->getLimit();
return array_slice($filteredModules, $firstResult, $remainingItems, true);
}
private function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,52 @@
<?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\Search\LiveSearch;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
enum DatabaseRecordActionType: string
{
case EDIT = 'edit';
case LIST = 'list';
case LAYOUT = 'layout';
case PREVIEW = 'preview';
/**
* Resolve the default action identifier from TSconfig:
* 1. Table-specific default (options.liveSearch.actions.<TABLE>.default)
* 2. Global default (options.liveSearch.actions.default)
* 3. Fallback to EDIT (for pages to LAYOUT)
* The value is then converted to a DatabaseRecordActionType enum;
* if conversion fails, fallback is used as a safe default.
*
* @param BackendUserAuthentication $backendUser The current backend user
* @param string $table The table name to find the default action for
* @return DatabaseRecordActionType NULL if file is missing or deleted, the generated url otherwise
*/
public static function fromUserForTable(BackendUserAuthentication $backendUser, string $table): DatabaseRecordActionType
{
$defaultAction = $table === 'pages' ? self::LAYOUT : self::EDIT;
$userTsConfig = $backendUser->getTSConfig();
return self::tryFrom(
$userTsConfig['options.']['liveSearch.']['actions.'][$table . '.']['default']
?? $userTsConfig['options.']['liveSearch.']['actions.']['default']
?? $defaultAction->value
) ?? $defaultAction;
}
}
@@ -0,0 +1,600 @@
<?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\Search\LiveSearch;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform as DoctrinePostgreSQLPlatform;
use Psr\EventDispatcher\EventDispatcherInterface;
use TYPO3\CMS\Backend\Routing\PreviewUriBuilder;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Search\Event\BeforeSearchInDatabaseRecordProviderEvent;
use TYPO3\CMS\Backend\Search\Event\ModifyConstraintsForLiveSearchEvent;
use TYPO3\CMS\Backend\Search\Event\ModifyQueryForLiveSearchEvent;
use TYPO3\CMS\Backend\Search\LiveSearch\SearchDemand\DemandProperty;
use TYPO3\CMS\Backend\Search\LiveSearch\SearchDemand\DemandPropertyName;
use TYPO3\CMS\Backend\Search\LiveSearch\SearchDemand\SearchDemand;
use TYPO3\CMS\Backend\Tree\Repository\PageTreeRepository;
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\Expression\CompositeExpression;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction;
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Localization\LanguageServiceFactory;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\Field\DateTimeFieldType;
use TYPO3\CMS\Core\Schema\Field\NumberFieldType;
use TYPO3\CMS\Core\Schema\SearchableSchemaFieldsCollector;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* Search provider to query records from database
*
* @internal
*/
final class DatabaseRecordProvider implements SearchProviderInterface
{
private const int RECURSIVE_PAGE_LEVEL = 99;
private LanguageService $languageService;
private string $userPermissions;
private array $pageIdList = [];
public function __construct(
private readonly EventDispatcherInterface $eventDispatcher,
private readonly IconFactory $iconFactory,
private readonly LanguageServiceFactory $languageServiceFactory,
private readonly SiteFinder $siteFinder,
private readonly UriBuilder $uriBuilder,
private readonly QueryParser $queryParser,
private readonly SearchableSchemaFieldsCollector $searchableSchemaFieldsCollector,
private readonly TcaSchemaFactory $tcaSchemaFactory,
private readonly ConnectionPool $connectionPool,
) {
$this->languageService = $this->languageServiceFactory->createFromUserPreferences($this->getBackendUser());
$this->userPermissions = $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW);
}
public function getFilterLabel(): string
{
return $this->languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang.xlf:liveSearch.databaseRecordProvider.filterLabel');
}
public function count(SearchDemand $searchDemand): int
{
$count = 0;
$event = $this->eventDispatcher->dispatch(
new BeforeSearchInDatabaseRecordProviderEvent($this->getPageIdList(), $searchDemand)
);
$this->pageIdList = $event->getSearchPageIds();
$searchDemand = $event->getSearchDemand();
$accessibleTables = $this->getAccessibleTables($event);
$parsedCommand = $this->parseCommand($searchDemand);
$searchDemand = $parsedCommand['searchDemand'];
if ($parsedCommand['table'] !== null && in_array($parsedCommand['table'], $accessibleTables)) {
$accessibleTables = [$parsedCommand['table']];
}
foreach ($accessibleTables as $tableName) {
$count += $this->countByTable($searchDemand, $tableName);
}
return $count;
}
/**
* @return ResultItem[]
*/
public function find(SearchDemand $searchDemand): array
{
$result = [];
$remainingItems = $searchDemand->getLimit();
$offset = $searchDemand->getOffset();
if ($remainingItems < 1) {
return [];
}
$event = $this->eventDispatcher->dispatch(
new BeforeSearchInDatabaseRecordProviderEvent($this->getPageIdList(), $searchDemand)
);
$this->pageIdList = $event->getSearchPageIds();
$searchDemand = $event->getSearchDemand();
$accessibleTables = $this->getAccessibleTables($event);
$parsedCommand = $this->parseCommand($searchDemand);
$searchDemand = $parsedCommand['searchDemand'];
if ($parsedCommand['table'] !== null && in_array($parsedCommand['table'], $accessibleTables)) {
$accessibleTables = [$parsedCommand['table']];
}
foreach ($accessibleTables as $tableName) {
if ($remainingItems < 1) {
break;
}
// To have a reliable offset calculation across several database tables, we have to count the amount of
// records and subtract the amount from the offset to be used, IF the amount is smaller than the requested
// offset. At any point, the offset will be smaller than the amount of records, which will then be used in
// ->findByTable().
// If any subsequent ->findByTable() call returns a result, the offset becomes irrelevant and is then zeroed.
if ($offset > 0) {
$tableCount = $this->countByTable($searchDemand, $tableName);
if ($tableCount <= $offset) {
$offset = max(0, $offset - $tableCount);
continue;
}
}
$tableResult = $this->findByTable($searchDemand, $tableName, $remainingItems, $offset);
if ($tableResult !== []) {
$remainingItems -= count($tableResult);
$offset = 0;
$result[] = $tableResult;
}
}
return array_merge([], ...$result);
}
private function parseCommand(SearchDemand $searchDemand): array
{
$tableName = null;
$commandQuery = null;
$query = $searchDemand->getQuery();
if ($this->queryParser->isValidCommand($query)) {
$commandQuery = $query;
} elseif ($this->queryParser->isValidPageJump($query)) {
$commandQuery = $this->queryParser->getCommandForPageJump($query);
}
if ($commandQuery !== null) {
$tableName = $this->queryParser->getTableNameFromCommand($query);
$extractedQueryString = $this->queryParser->getSearchQueryValue($commandQuery);
$searchDemand = new SearchDemand([
new DemandProperty(DemandPropertyName::query, $extractedQueryString),
...array_filter(
$searchDemand->getProperties(),
static fn(DemandProperty $demandProperty): bool => $demandProperty->getName() !== DemandPropertyName::query
),
]);
}
return [
'searchDemand' => $searchDemand,
'table' => $tableName,
];
}
private function getQueryBuilderForTable(SearchDemand $searchDemand, string $tableName): ?QueryBuilder
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable($tableName);
$queryBuilder->getRestrictions()
->removeAll()
->add(GeneralUtility::makeInstance(DeletedRestriction::class))
->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->getBackendUser()->workspace, true));
$constraints = $this->buildConstraintsForTable($searchDemand->getQuery(), $queryBuilder, $tableName);
$event = $this->eventDispatcher->dispatch(new ModifyConstraintsForLiveSearchEvent($constraints, $tableName, $searchDemand));
$constraints = $event->getConstraints();
if ($constraints === []) {
return null;
}
$queryBuilder
->from($tableName)
->where(
$queryBuilder->expr()->or(...$constraints)
);
if ($this->pageIdList !== []) {
$queryBuilder->andWhere(
$queryBuilder->expr()->in(
'pid',
$queryBuilder->createNamedParameter($this->pageIdList, ArrayParameterType::INTEGER)
)
);
}
/** @var ModifyQueryForLiveSearchEvent $event */
$event = $this->eventDispatcher->dispatch(new ModifyQueryForLiveSearchEvent($queryBuilder, $tableName));
return $event->getQueryBuilder();
}
private function countByTable(SearchDemand $searchDemand, string $tableName): int
{
$queryBuilder = $this->getQueryBuilderForTable($searchDemand, $tableName);
return (int)$queryBuilder?->count('*')->executeQuery()->fetchOne();
}
/**
* @return ResultItem[]
*/
private function findByTable(SearchDemand $searchDemand, string $tableName, int $limit, int $offset): array
{
$queryBuilder = $this->getQueryBuilderForTable($searchDemand, $tableName);
if ($queryBuilder === null) {
return [];
}
$queryBuilder
->select('*')
->setFirstResult($offset)
->setMaxResults($limit);
$queryBuilder->addOrderBy('uid', 'DESC');
$items = [];
$result = $queryBuilder->executeQuery();
$schema = $this->tcaSchemaFactory->get($tableName);
$rootLevelCapability = $schema->getCapability(TcaSchemaCapability::RestrictionRootLevel);
$hasWorkspaceCapability = $schema->hasCapability(TcaSchemaCapability::Workspace);
while ($row = $result->fetchAssociative()) {
BackendUtility::workspaceOL($tableName, $row);
if (!is_array($row)) {
continue;
}
$actions = [];
$editActionLink = $this->getEditActionLink($tableName, $row);
if ($editActionLink !== '') {
$actions[DatabaseRecordActionType::EDIT->value] = (new ResultItemAction(DatabaseRecordActionType::EDIT->value))
->setLabel($this->languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.edit'))
->setIcon($this->iconFactory->getIcon('actions-open', IconSize::SMALL))
->setUrl($editActionLink);
}
$layoutActionLink = $this->getLayoutActionLink($tableName, $row);
if ($layoutActionLink !== '') {
$actions[DatabaseRecordActionType::LAYOUT->value] = (new ResultItemAction(DatabaseRecordActionType::LAYOUT->value))
->setLabel($this->languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.view.layout'))
->setIcon($this->iconFactory->getIcon('actions-viewmode-layout', IconSize::SMALL))
->setUrl($layoutActionLink);
}
$listActionLink = $this->getRecordsActionLink($tableName, $row);
if ($listActionLink !== '') {
$actions[DatabaseRecordActionType::LIST->value] = (new ResultItemAction(DatabaseRecordActionType::LIST->value))
->setLabel($this->languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.showList'))
->setIcon($this->iconFactory->getIcon('actions-list', IconSize::SMALL))
->setUrl($listActionLink);
}
$previewActionLink = $this->getPreviewActionLink($tableName, $row);
if ($previewActionLink !== '') {
$actions[DatabaseRecordActionType::PREVIEW->value] = (new ResultItemAction(DatabaseRecordActionType::PREVIEW->value))
->setLabel($this->languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.showPage'))
->setIcon($this->iconFactory->getIcon('actions-file-view', IconSize::SMALL))
->setUrl($previewActionLink);
}
// Find the default action
$defaultActionIdentifier = DatabaseRecordActionType::fromUserForTable($this->getBackendUser(), $tableName);
$defaultAction = $actions[$defaultActionIdentifier->value] ?? null;
$extraData = [
'table' => $tableName,
'uid' => $row['uid'],
'inWorkspace' => $hasWorkspaceCapability && $row['t3ver_wsid'] > 0,
];
if ($rootLevelCapability->canExistOnPages()) {
$extraData['breadcrumb'] = BackendUtility::getRecordPath($row['pid'], 'AND ' . $this->userPermissions, 0);
}
$language = null;
if ($schema->hasCapability(TcaSchemaCapability::Language)) {
$languageCapability = $schema->getCapability(TcaSchemaCapability::Language);
$languageFieldName = $languageCapability->getLanguageField()->getName();
$languageId = (int)($row[$languageFieldName] ?? 0);
$language = $this->resolveLanguage((int)($row['pid'] ?? 0), $languageId);
}
$icon = $this->iconFactory->getIconForRecord($tableName, $row, IconSize::SMALL);
$recordTitle = BackendUtility::getRecordTitle($tableName, $row);
$items[] = (new ResultItem(self::class))
->setItemTitle(BackendUtility::cropToTitleLength($recordTitle))
->setTypeLabel($schema->getTitle($this->languageService->sL(...)) ?: $tableName)
->setIcon($icon)
->setActions(...array_values($actions))
->setDefaultAction($defaultAction)
->setLanguage($language)
->setExtraData($extraData)
->setInternalData([
'row' => $row,
])
;
}
return $items;
}
private function canAccessTable(string $tableName): bool
{
if (!$this->tcaSchemaFactory->has($tableName)) {
return true;
}
$schema = $this->tcaSchemaFactory->get($tableName);
if ($schema->hasCapability(TcaSchemaCapability::HideInUi)) {
return false;
}
if (!$this->getBackendUser()->check('tables_select', $tableName)
&& !$this->getBackendUser()->check('tables_modify', $tableName)) {
return false;
}
return true;
}
private function getAccessibleTables(BeforeSearchInDatabaseRecordProviderEvent $event): array
{
return array_filter($this->tcaSchemaFactory->all()->getNames(), function (string $tableName) use ($event): bool {
return $this->canAccessTable($tableName) && !$event->isTableIgnored($tableName);
});
}
/**
* List of available page uids for user, empty array for admin users.
*
* @return int[]
*/
private function getPageIdList(): array
{
if ($this->getBackendUser()->isAdmin()) {
return [];
}
$mounts = $this->getBackendUser()->getWebmounts();
$pageList = $mounts;
$repository = GeneralUtility::makeInstance(PageTreeRepository::class);
$repository->setAdditionalWhereClause($this->userPermissions);
$pages = $repository->getFlattenedPages($mounts, self::RECURSIVE_PAGE_LEVEL);
foreach ($pages as $page) {
$pageList[] = (int)$page['uid'];
}
return $pageList;
}
/**
* @return CompositeExpression[]
*/
private function buildConstraintsForTable(string $queryString, QueryBuilder $queryBuilder, string $tableName): array
{
$platform = $queryBuilder->getConnection()->getDatabasePlatform();
$isPostgres = $platform instanceof DoctrinePostgreSQLPlatform;
$fieldsToSearchWithin = $this->searchableSchemaFieldsCollector->getFields($tableName);
[$subSchemaDivisorFieldName, $fieldsSubSchemaTypes] = $this->searchableSchemaFieldsCollector->getSchemaFieldSubSchemaTypes($tableName);
$constraints = [];
// If the search string is a simple integer, assemble an equality comparison
if (MathUtility::canBeInterpretedAsInteger($queryString)) {
// Add uid and pid constraint
$constraints[] = $queryBuilder->expr()->eq(
'uid',
$queryBuilder->createNamedParameter($queryString, Connection::PARAM_INT)
);
$constraints[] = $queryBuilder->expr()->eq(
'pid',
$queryBuilder->createNamedParameter($queryString, Connection::PARAM_INT)
);
foreach ($fieldsToSearchWithin as $fieldName => $field) {
// Assemble the search condition only if the field is an integer
if ($field instanceof NumberFieldType || $field instanceof DateTimeFieldType) {
$searchConstraint = $queryBuilder->expr()->eq(
$fieldName,
$queryBuilder->createNamedParameter($queryString, Connection::PARAM_INT)
);
} else {
// Otherwise assemble a like condition
$searchConstraint = $queryBuilder->expr()->like(
$fieldName,
$queryBuilder->createNamedParameter(
'%' . $queryBuilder->escapeLikeWildcards($queryString) . '%'
)
);
}
// If this table has subtypes (e.g. tt_content.CType), we want to ensure that only CType that contain
// e.g. "bodytext" in their list of fields, to search through them. This is important when a field
// is filled but its type has been changed.
if ($subSchemaDivisorFieldName !== ''
&& isset($fieldsSubSchemaTypes[$fieldName])
&& $fieldsSubSchemaTypes[$fieldName] !== []
) {
// Using `IN()` with a string-value quoted list is fine for all database systems, even when
// used on integer-typed fields and no additional work required here to mitigate something.
$searchConstraint = $queryBuilder->expr()->and(
$searchConstraint,
$queryBuilder->expr()->in(
$subSchemaDivisorFieldName,
$queryBuilder->quoteArrayBasedValueListToStringList($fieldsSubSchemaTypes[$fieldName])
),
);
}
$constraints[] = $searchConstraint;
}
} else {
$like = '%' . $queryBuilder->escapeLikeWildcards($queryString) . '%';
foreach ($fieldsToSearchWithin as $fieldName => $field) {
// Enforce case-insensitive comparison by lower-casing field and value, unrelated to charset/collation
// on MySQL/MariaDB, for example if column collation is `utf8mb4_bin` - which would be case-sensitive.
$preparedFieldName = $isPostgres
? $queryBuilder->castFieldToTextType($fieldName)
: $queryBuilder->quoteIdentifier($fieldName);
$searchConstraint = $queryBuilder->expr()->comparison(
'LOWER(' . $preparedFieldName . ')',
'LIKE',
$queryBuilder->createNamedParameter(mb_strtolower($like))
);
// If this table has subtypes (e.g. tt_content.CType), we want to ensure that only CType that contain
// e.g. "bodytext" in their list of fields, to search through them. This is important when a field
// is filled but its type has been changed.
if ($subSchemaDivisorFieldName !== ''
&& isset($fieldsSubSchemaTypes[$fieldName])
&& $fieldsSubSchemaTypes[$fieldName] !== []
) {
// Using `IN()` with a string-value quoted list is fine for all database systems, even when
// used on integer-typed fields and no additional work required here to mitigate something.
$searchConstraint = $queryBuilder->expr()->and(
$searchConstraint,
$queryBuilder->expr()->in(
$subSchemaDivisorFieldName,
$queryBuilder->quoteArrayBasedValueListToStringList($fieldsSubSchemaTypes[$fieldName])
),
);
}
$constraints[] = $searchConstraint;
}
}
return $constraints;
}
/**
* Build a backend edit link based on given record.
*
* @param string $tableName Record table name
* @param array $row Current record row from database.
* @return string Link to open an edit window for record.
* @see \TYPO3\CMS\Backend\Utility\BackendUtility::readPageAccess()
*/
private function getEditActionLink(string $tableName, array $row): string
{
$backendUser = $this->getBackendUser();
$editLink = '';
$permissionSet = new Permission($backendUser->calcPerms(BackendUtility::readPageAccess($row['pid'], $this->userPermissions) ?: []));
$schema = $this->tcaSchemaFactory->get($tableName);
if (!$schema->hasCapability(TcaSchemaCapability::AccessReadOnly)
&& (
$backendUser->isAdmin()
|| (
$permissionSet->editContentPermissionIsGranted()
&& !$schema->hasCapability(TcaSchemaCapability::AccessAdminOnly)
&& $backendUser->check('tables_modify', $tableName)
&& $backendUser->checkRecordEditAccess($tableName, $row)->isAllowed
)
)
) {
// @todo pass module context to live search and pass module context to edit link and use for return url
$returnUrl = (string)$this->uriBuilder->buildUriFromRoute('records', ['id' => $row['pid']]);
$editLink = (string)$this->uriBuilder->buildUriFromRoute('record_edit', [
'edit[' . $tableName . '][' . $row['uid'] . ']' => 'edit',
'returnUrl' => $returnUrl,
]);
}
return $editLink;
}
/**
* Build a link to the page layout for the given record.
*
* @param array $row Current record row from database.
* @return string Link to open an edit window for record.
*/
private function getLayoutActionLink(string $tableName, array $row): string
{
$showLink = '';
if ($tableName !== 'tt_content') {
return $showLink;
}
if ($this->hasPagesAccess($row)) {
$parameter = [
'id' => $row['pid'],
'languages' => [$row['language_tag']],
];
$showLink = ((string)$this->uriBuilder->buildUriFromRoute('web_layout', $parameter)) . '#element-' . $tableName . '-' . $row['uid'];
}
return $showLink;
}
/**
* Build a link to the record list based on given record.
*
* @param array $row Current record row from database.
* @return string Link to open an edit window for record.
*/
private function getRecordsActionLink(string $table, array $row): string
{
return $this->hasPagesAccess($row) ? (((string)$this->uriBuilder->buildUriFromRoute('records', ['id' => $row['pid']])) . '#t3-table-' . $table) : '';
}
/**
* Build a preview link to display the record in the frontend.
*
* @param array $row Current record row from database.
* @return string Link to open an edit window for record.
*/
private function getPreviewActionLink(string $table, array $row): string
{
$previewLink = '';
if ($this->hasPagesAccess($row)) {
$previewUriBuilder = PreviewUriBuilder::createForRecordPreview($table, $row, (int)($row['pid'] ?? 0));
if ($previewUriBuilder->isPreviewable()) {
$previewLink = (string)$previewUriBuilder->buildUri();
}
}
return $previewLink;
}
private function hasPagesAccess(array $row): bool
{
$backendUser = $this->getBackendUser();
$permissionSet = new Permission($backendUser->calcPerms(BackendUtility::getRecord('pages', $row['pid']) ?? []));
$pagesSchema = $this->tcaSchemaFactory->get('pages');
return $backendUser->isAdmin()
|| (
$permissionSet->showPagePermissionIsGranted()
&& !$pagesSchema->hasCapability(TcaSchemaCapability::AccessAdminOnly)
&& $backendUser->check('tables_select', 'pages')
);
}
private function resolveLanguage(int $pageUid, int $languageId): ?array
{
try {
$siteLanguage = $this->siteFinder->getSiteByPageId($pageUid)->getLanguageById($languageId);
return [
'id' => $siteLanguage->getLanguageId(),
'title' => $siteLanguage->getTitle(),
'iconIdentifier' => $siteLanguage->getFlagIdentifier(),
];
} catch (SiteNotFoundException|\InvalidArgumentException) {
return null;
}
}
private function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,508 @@
<?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\Search\LiveSearch;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform as DoctrinePostgreSQLPlatform;
use Psr\EventDispatcher\EventDispatcherInterface;
use TYPO3\CMS\Backend\Routing\PreviewUriBuilder;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Search\Event\ModifyConstraintsForLiveSearchEvent;
use TYPO3\CMS\Backend\Search\Event\ModifyQueryForLiveSearchEvent;
use TYPO3\CMS\Backend\Search\LiveSearch\SearchDemand\DemandProperty;
use TYPO3\CMS\Backend\Search\LiveSearch\SearchDemand\DemandPropertyName;
use TYPO3\CMS\Backend\Search\LiveSearch\SearchDemand\SearchDemand;
use TYPO3\CMS\Backend\Tree\Repository\PageTreeRepository;
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\Expression\CompositeExpression;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction;
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Localization\LanguageServiceFactory;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\Field\DateTimeFieldType;
use TYPO3\CMS\Core\Schema\Field\NumberFieldType;
use TYPO3\CMS\Core\Schema\SearchableSchemaFieldsCollector;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* Search provider to query pages from database
*
* @internal
*/
final class PageRecordProvider implements SearchProviderInterface
{
private const int RECURSIVE_PAGE_LEVEL = 99;
private LanguageService $languageService;
private string $userPermissions;
private array $pageIdList = [];
public function __construct(
private readonly EventDispatcherInterface $eventDispatcher,
private readonly IconFactory $iconFactory,
private readonly LanguageServiceFactory $languageServiceFactory,
private readonly UriBuilder $uriBuilder,
private readonly QueryParser $queryParser,
private readonly SiteFinder $siteFinder,
private readonly SearchableSchemaFieldsCollector $searchableSchemaFieldsCollector,
private readonly TcaSchemaFactory $tcaSchemaFactory,
private readonly ConnectionPool $connectionPool,
) {
$this->languageService = $this->languageServiceFactory->createFromUserPreferences($this->getBackendUser());
$this->userPermissions = $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW);
}
public function getFilterLabel(): string
{
return $this->languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang.xlf:liveSearch.pageRecordProvider.filterLabel');
}
public function count(SearchDemand $searchDemand): int
{
$searchDemand = $this->parseCommand($searchDemand);
$queryBuilder = $this->getQueryBuilderForTable($searchDemand);
return (int)$queryBuilder?->count('*')->executeQuery()->fetchOne();
}
/**
* @return ResultItem[]
*/
public function find(SearchDemand $searchDemand): array
{
$this->pageIdList = $this->getPageIdList();
$result = [];
$remainingItems = $searchDemand->getLimit();
$searchDemand = $this->parseCommand($searchDemand);
$tableResult = $this->findByTable($searchDemand, $remainingItems);
$result[] = $tableResult;
return array_merge([], ...$result);
}
private function parseCommand(SearchDemand $searchDemand): SearchDemand
{
$commandQuery = null;
$query = $searchDemand->getQuery();
if ($this->queryParser->isValidCommand($query)) {
$commandQuery = $query;
} elseif ($this->queryParser->isValidPageJump($query)) {
$commandQuery = $this->queryParser->getCommandForPageJump($query);
}
if ($commandQuery !== null) {
$tableName = $this->queryParser->getTableNameFromCommand($query);
if ($tableName === 'pages') {
$extractedQueryString = $this->queryParser->getSearchQueryValue($commandQuery);
$searchDemand = new SearchDemand([
new DemandProperty(DemandPropertyName::query, $extractedQueryString),
...array_filter(
$searchDemand->getProperties(),
static fn(DemandProperty $demandProperty): bool => $demandProperty->getName() !== DemandPropertyName::query
),
]);
}
}
return $searchDemand;
}
private function getQueryBuilderForTable(SearchDemand $searchDemand): ?QueryBuilder
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
$queryBuilder->getRestrictions()
->removeAll()
->add(GeneralUtility::makeInstance(DeletedRestriction::class))
->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->getBackendUser()->workspace, true));
$constraints = $this->buildConstraintsForTable($searchDemand->getQuery(), $queryBuilder);
$event = $this->eventDispatcher->dispatch(new ModifyConstraintsForLiveSearchEvent($constraints, 'pages', $searchDemand));
$constraints = $event->getConstraints();
if ($constraints === []) {
return null;
}
$queryBuilder
->from('pages')
->where(
$queryBuilder->expr()->or(...$constraints)
);
if ($this->userPermissions) {
$queryBuilder->andWhere($this->userPermissions);
}
if ($this->pageIdList !== []) {
$queryBuilder->andWhere(
$queryBuilder->expr()->in(
'pid',
$queryBuilder->createNamedParameter($this->pageIdList, ArrayParameterType::INTEGER)
)
);
}
$event = $this->eventDispatcher->dispatch(new ModifyQueryForLiveSearchEvent($queryBuilder, 'pages'));
return $event->getQueryBuilder();
}
/**
* @return ResultItem[]
*/
private function findByTable(SearchDemand $searchDemand, int $limit): array
{
$queryBuilder = $this->getQueryBuilderForTable($searchDemand);
if ($queryBuilder === null) {
return [];
}
$queryBuilder
->select('*')
->setFirstResult($searchDemand->getOffset())
->setMaxResults($limit)
->addOrderBy('uid', 'DESC');
$queryBuilder->addOrderBy('uid', 'DESC');
$items = [];
$result = $queryBuilder->executeQuery();
$schema = $this->tcaSchemaFactory->get('pages');
$hasWorkspaceCapability = $schema->hasCapability(TcaSchemaCapability::Workspace);
while ($row = $result->fetchAssociative()) {
BackendUtility::workspaceOL('pages', $row);
if (!is_array($row)) {
continue;
}
$actions = [];
$editActionLink = $this->getEditActionLink($row);
if ($editActionLink !== '') {
$actions[DatabaseRecordActionType::EDIT->value] = (new ResultItemAction(DatabaseRecordActionType::EDIT->value))
->setLabel($this->languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.edit'))
->setIcon($this->iconFactory->getIcon('actions-open', IconSize::SMALL))
->setUrl($editActionLink);
}
$layoutActionLink = $this->getLayoutActionLink($row);
if ($layoutActionLink !== '') {
$actions[DatabaseRecordActionType::LAYOUT->value] = (new ResultItemAction(DatabaseRecordActionType::LAYOUT->value))
->setLabel($this->languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.view.layout'))
->setIcon($this->iconFactory->getIcon('actions-viewmode-layout', IconSize::SMALL))
->setUrl($layoutActionLink);
}
$listActionLink = $this->getRecordsActionLink($row);
if ($listActionLink !== '') {
$actions[DatabaseRecordActionType::LIST->value] = (new ResultItemAction(DatabaseRecordActionType::LIST->value))
->setLabel($this->languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.showList'))
->setIcon($this->iconFactory->getIcon('actions-list', IconSize::SMALL))
->setUrl($listActionLink);
}
$previewActionLink = $this->getPreviewActionLink($row);
if ($previewActionLink !== '') {
$actions[DatabaseRecordActionType::PREVIEW->value] = (new ResultItemAction(DatabaseRecordActionType::PREVIEW->value))
->setLabel($this->languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.showPage'))
->setIcon($this->iconFactory->getIcon('actions-file-view', IconSize::SMALL))
->setUrl($previewActionLink);
}
// Find the default action
$defaultActionIdentifier = DatabaseRecordActionType::fromUserForTable($this->getBackendUser(), 'pages');
$defaultAction = $actions[$defaultActionIdentifier->value] ?? null;
$icon = $this->iconFactory->getIconForRecord('pages', $row, IconSize::SMALL);
$items[] = (new ResultItem(self::class))
->setItemTitle(BackendUtility::getRecordTitle('pages', $row))
->setTypeLabel($schema->getTitle($this->languageService->sL(...)))
->setIcon($icon)
->setActions(...array_values($actions))
->setDefaultAction($defaultAction)
->setLanguage($this->resolveLanguage($row['l10n_source'] > 0 ? $row['l10n_source'] : $row['uid'], (int)$row['language_tag']))
->setExtraData([
'breadcrumb' => BackendUtility::getRecordPath($row['pid'], 'AND ' . $this->userPermissions, 0),
'inWorkspace' => $hasWorkspaceCapability && $row['t3ver_wsid'] > 0,
])
->setInternalData([
'row' => $row,
])
;
}
return $items;
}
/**
* List of available page uids for user, empty array for admin users.
*
* @return int[]
*/
private function getPageIdList(): array
{
if ($this->getBackendUser()->isAdmin()) {
return [];
}
$mounts = $this->getBackendUser()->getWebmounts();
$pageList = $mounts;
$repository = GeneralUtility::makeInstance(PageTreeRepository::class);
$repository->setAdditionalWhereClause($this->userPermissions);
$pages = $repository->getFlattenedPages($mounts, self::RECURSIVE_PAGE_LEVEL);
foreach ($pages as $page) {
$pageList[] = (int)$page['uid'];
}
return $pageList;
}
/**
* @return CompositeExpression[]
*/
private function buildConstraintsForTable(string $queryString, QueryBuilder $queryBuilder): array
{
$platform = $queryBuilder->getConnection()->getDatabasePlatform();
$isPostgres = $platform instanceof DoctrinePostgreSQLPlatform;
$fieldsToSearchWithin = $this->searchableSchemaFieldsCollector->getFields('pages');
[$subSchemaDivisorFieldName, $fieldsSubSchemaTypes] = $this->searchableSchemaFieldsCollector->getSchemaFieldSubSchemaTypes('pages');
$constraints = [];
// If the search string is a simple integer, assemble an equality comparison
if (MathUtility::canBeInterpretedAsInteger($queryString)) {
// Add uid and pid constraint
$constraints[] = $queryBuilder->expr()->eq(
'uid',
$queryBuilder->createNamedParameter($queryString, Connection::PARAM_INT)
);
$constraints[] = $queryBuilder->expr()->eq(
'pid',
$queryBuilder->createNamedParameter($queryString, Connection::PARAM_INT)
);
foreach ($fieldsToSearchWithin as $fieldName => $field) {
// Assemble the search condition only if the field is an integer
if ($field instanceof NumberFieldType || $field instanceof DateTimeFieldType) {
$searchConstraint = $queryBuilder->expr()->eq(
$fieldName,
$queryBuilder->createNamedParameter($queryString, Connection::PARAM_INT)
);
} else {
// Otherwise assemble a like condition
$searchConstraint = $queryBuilder->expr()->like(
$fieldName,
$queryBuilder->createNamedParameter(
'%' . $queryBuilder->escapeLikeWildcards($queryString) . '%'
)
);
}
// If this table has subtypes (e.g. tt_content.CType), we want to ensure that only CType that contain
// e.g. "bodytext" in their list of fields, to search through them. This is important when a field
// is filled but its type has been changed.
if ($subSchemaDivisorFieldName !== ''
&& isset($fieldsSubSchemaTypes[$fieldName])
&& $fieldsSubSchemaTypes[$fieldName] !== []
) {
// Using `IN()` with a string-value quoted list is fine for all database systems, even when
// used on integer-typed fields and no additional work required here to mitigate something.
$searchConstraint = $queryBuilder->expr()->and(
$searchConstraint,
$queryBuilder->expr()->in(
$subSchemaDivisorFieldName,
$queryBuilder->quoteArrayBasedValueListToStringList($fieldsSubSchemaTypes[$fieldName])
),
);
}
$constraints[] = $searchConstraint;
}
} else {
$like = '%' . $queryBuilder->escapeLikeWildcards($queryString) . '%';
foreach ($fieldsToSearchWithin as $fieldName => $field) {
$fieldConfig = $field->getConfiguration();
// Enforce case-insensitive comparison by lower-casing field and value, unrelated to charset/collation
// on MySQL/MariaDB, for example if column collation is `utf8mb4_bin` - which would be case-sensitive.
$preparedFieldName = $isPostgres
? $queryBuilder->castFieldToTextType($fieldName)
: $queryBuilder->quoteIdentifier($fieldName);
$searchConstraint = $queryBuilder->expr()->comparison(
'LOWER(' . $preparedFieldName . ')',
'LIKE',
$queryBuilder->createNamedParameter(mb_strtolower($like))
);
// If this table has subtypes (e.g. tt_content.CType), we want to ensure that only CType that contain
// e.g. "bodytext" in their list of fields, to search through them. This is important when a field
// is filled but its type has been changed.
if ($subSchemaDivisorFieldName !== ''
&& isset($fieldsSubSchemaTypes[$fieldName])
&& $fieldsSubSchemaTypes[$fieldName] !== []
) {
// Using `IN()` with a string-value quoted list is fine for all database systems, even when
// used on integer-typed fields and no additional work required here to mitigate something.
$searchConstraint = $queryBuilder->expr()->and(
$searchConstraint,
$queryBuilder->expr()->in(
$subSchemaDivisorFieldName,
$queryBuilder->quoteArrayBasedValueListToStringList($fieldsSubSchemaTypes[$fieldName])
),
);
}
$constraints[] = $searchConstraint;
}
}
return $constraints;
}
/**
* Build a backend edit link based on given page.
*
* @param array $row Current page row from database.
* @return string Link to open an edit window for page.
* @see \TYPO3\CMS\Backend\Utility\BackendUtility::readPageAccess()
*/
private function getEditActionLink(array $row): string
{
$backendUser = $this->getBackendUser();
$editLink = '';
$permissionSet = new Permission($backendUser->calcPerms(BackendUtility::readPageAccess($row['uid'], $this->userPermissions) ?: []));
$schema = $this->tcaSchemaFactory->get('pages');
if (!$schema->hasCapability(TcaSchemaCapability::AccessReadOnly)
&& (
$backendUser->isAdmin()
|| (
$permissionSet->editContentPermissionIsGranted()
&& !$schema->hasCapability(TcaSchemaCapability::AccessAdminOnly)
&& $backendUser->check('tables_modify', 'pages')
&& $backendUser->checkRecordEditAccess('pages', $row)->isAllowed
)
)
) {
$returnUrl = (string)$this->uriBuilder->buildUriFromRoute('web_layout', ['id' => $row['uid']]);
$editLink = (string)$this->uriBuilder->buildUriFromRoute('record_edit', [
'edit[pages][' . $row['uid'] . ']' => 'edit',
'returnUrl' => $returnUrl,
]);
}
return $editLink;
}
/**
* Build a link to the page layout for the given record.
*
* @param array $row Current record row from database.
* @return string Link to open an edit window for record.
*/
private function getLayoutActionLink(array $row): string
{
$showLink = '';
if ($this->hasPagesAccess($row)) {
$parameter = [
'id' => $row['language_tag'] === 0 ? $row['uid'] : $row['l10n_parent'],
'languages' => [$row['language_tag']],
];
$showLink = (string)$this->uriBuilder->buildUriFromRoute('web_layout', $parameter);
}
return $showLink;
}
/**
* Build a link to the record list based on given record.
*
* @param array $row Current record row from database.
* @return string Link to open an edit window for record.
*/
private function getRecordsActionLink(array $row): string
{
$showLink = '';
if ($this->hasPagesAccess($row)) {
$parameter = [
'id' => $row['language_tag'] === 0 ? $row['uid'] : $row['l10n_parent'],
'languages' => [$row['language_tag']],
];
$showLink = ((string)$this->uriBuilder->buildUriFromRoute('records', $parameter)) . '#t3-table-pages';
}
return $showLink;
}
/**
* Build a preview link to display the record in the frontend.
*
* @param array $row Current record row from database.
* @return string Link to open an edit window for record.
*/
private function getPreviewActionLink(array $row): string
{
$previewLink = '';
if ($this->hasPagesAccess($row)) {
$previewUriBuilder = PreviewUriBuilder::create($row);
if ($previewUriBuilder->isPreviewable()) {
$previewLink = (string)$previewUriBuilder->buildUri();
}
}
return $previewLink;
}
private function hasPagesAccess(array $row): bool
{
$backendUser = $this->getBackendUser();
$permissionSet = new Permission($backendUser->calcPerms(BackendUtility::getRecord('pages', $row['uid']) ?? []));
$pagesSchema = $this->tcaSchemaFactory->get('pages');
return $backendUser->isAdmin()
|| (
$permissionSet->showPagePermissionIsGranted()
&& !$pagesSchema->hasCapability(TcaSchemaCapability::AccessAdminOnly)
&& $backendUser->check('tables_select', 'pages')
);
}
private function resolveLanguage(int $pageUid, int $languageId): ?array
{
try {
$siteLanguage = $this->siteFinder->getSiteByPageId($pageUid)->getLanguageById($languageId);
return [
'id' => $siteLanguage->getLanguageId(),
'title' => $siteLanguage->getTitle(),
'iconIdentifier' => $siteLanguage->getFlagIdentifier(),
];
} catch (SiteNotFoundException|\InvalidArgumentException) {
return null;
}
}
private function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
+155
View File
@@ -0,0 +1,155 @@
<?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\Search\LiveSearch;
/**
* Class for parsing query parameters in backend live search.
* Detects searches for #pages:23 or #content:mycontent
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
class QueryParser
{
/**
* @var string
*/
public const COMMAND_KEY_INDICATOR = '#';
/**
* @var string
*/
public const COMMAND_SPLIT_INDICATOR = ':';
/**
* @var string
*/
protected $commandKey = '';
/**
* @var string
*/
protected $tableName = '';
/**
* Retrieve the validated command key
*
* @param string $query
*/
protected function extractKeyFromQuery($query)
{
[$this->commandKey] = explode(':', substr($query, 1));
}
/**
* Extract the search value from the full search query which contains also the command part.
*
* @param string $query For example #news:weather
* @return string The extracted search value
*/
public function getSearchQueryValue($query)
{
$this->extractKeyFromQuery($query);
return str_replace(self::COMMAND_KEY_INDICATOR . $this->commandKey . self::COMMAND_SPLIT_INDICATOR, '', $query);
}
/**
* Find the registered table command and retrieve the matching table name.
*
* @param string $query
* @return string Database Table name
*/
public function getTableNameFromCommand($query)
{
$tableName = '';
$this->extractKeyFromQuery($query);
if (array_key_exists($this->commandKey, $GLOBALS['TYPO3_CONF_VARS']['SYS']['livesearch'])) {
$tableName = $GLOBALS['TYPO3_CONF_VARS']['SYS']['livesearch'][$this->commandKey];
}
return $tableName;
}
/**
* Verify if a given query contains a page jump command.
*
* @param string $query A valid value looks like '#14'
* @return int
*/
public function getId($query)
{
return (int)str_replace(self::COMMAND_KEY_INDICATOR, '', $query);
}
/**
* Verify if a given query contains a page jump command.
*
* @param string $query A valid value looks like '#14'
* @return bool
*/
public function isValidPageJump($query)
{
$isValid = false;
if (preg_match('~^#(\\d)+$~', $query)) {
$isValid = true;
}
return $isValid;
}
/**
* Verify if a given query contains a registered command key.
*
* @param string $query
* @return bool
*/
public function isValidCommand($query)
{
$isValid = false;
if (str_starts_with($query, self::COMMAND_KEY_INDICATOR) && strpos($query, self::COMMAND_SPLIT_INDICATOR) > 1 && $this->getTableNameFromCommand($query)) {
$isValid = true;
}
return $isValid;
}
/**
* Gets the command for the given table.
*
* @param string $tableName The table to find a command for.
* @return string
*/
public function getCommandForTable($tableName)
{
$commandArray = array_keys($GLOBALS['TYPO3_CONF_VARS']['SYS']['livesearch'], $tableName);
return $commandArray[0] ?? '';
}
/**
* Gets the page jump command for a given query.
*
* @param string $query
* @return string
*/
public function getCommandForPageJump($query)
{
if ($this->isValidPageJump($query)) {
$command = $this->getCommandForTable('pages');
$id = $this->getId($query);
$resultQuery = self::COMMAND_KEY_INDICATOR . $command . self::COMMAND_SPLIT_INDICATOR . $id;
} else {
$resultQuery = false;
}
return $resultQuery;
}
}
+165
View File
@@ -0,0 +1,165 @@
<?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\Search\LiveSearch;
use TYPO3\CMS\Core\Imaging\Icon;
/**
* Class that represents a search result item
*
* @internal Class may change in further iterations, do not rely on it
*/
final class ResultItem implements \JsonSerializable
{
private string $itemTitle = '';
private string $typeLabel = '';
private ?Icon $icon = null;
private ?array $language = null;
private ?string $thumbnailUrl = null;
/**
* @var array<string, string>
*/
private array $properties = [];
/**
* @var ResultItemAction[]
*/
private array $actions = [];
private ?ResultItemAction $defaultAction = null;
private array $extraData = [];
private array $internalData = [];
/**
* @param class-string $providerClassName
*/
public function __construct(private readonly string $providerClassName) {}
public function getProviderClassName(): string
{
return $this->providerClassName;
}
public function setItemTitle(string $itemTitle): self
{
$this->itemTitle = $itemTitle;
return $this;
}
public function setTypeLabel(string $typeLabel): self
{
$this->typeLabel = $typeLabel;
return $this;
}
public function setIcon(Icon $icon): self
{
$this->icon = $icon;
return $this;
}
public function setThumbnailUrl(?string $thumbnailUrl): self
{
$this->thumbnailUrl = $thumbnailUrl;
return $this;
}
public function addProperty(string $label, string $value): self
{
$this->properties[$label] = $value;
return $this;
}
public function setActions(ResultItemAction ...$action): self
{
$this->actions = $action;
return $this;
}
public function addAction(ResultItemAction $action): self
{
$this->actions[] = $action;
return $this;
}
public function getDefaultAction(): ?ResultItemAction
{
return $this->defaultAction;
}
public function setDefaultAction(?ResultItemAction $defaultAction): ResultItem
{
$this->defaultAction = $defaultAction;
return $this;
}
public function getExtraData(): array
{
return $this->extraData;
}
public function setExtraData(array $extraData): ResultItem
{
$this->extraData = $extraData;
return $this;
}
public function getInternalData(): array
{
return $this->internalData;
}
public function setInternalData(array $internalData): ResultItem
{
$this->internalData = $internalData;
return $this;
}
public function setLanguage(?array $language): self
{
$this->language = $language;
return $this;
}
public function jsonSerialize(): array
{
return [
'provider' => $this->providerClassName,
'itemTitle' => $this->itemTitle,
'typeLabel' => $this->typeLabel,
'icon' => [
'identifier' => $this->icon?->getIdentifier(),
'overlay' => $this->icon?->getOverlayIcon()?->getIdentifier(),
],
'thumbnailUrl' => $this->thumbnailUrl,
'properties' => $this->properties,
'actions' => $this->actions,
'defaultAction' => $this->defaultAction ?? $this->actions[0],
'language' => $this->language,
'extraData' => $this->extraData,
];
}
}
@@ -0,0 +1,76 @@
<?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\Search\LiveSearch;
use TYPO3\CMS\Core\Imaging\Icon;
/**
* Class that represents a search result item action
*
* @internal Class may change in further iterations, do not rely on it
*/
final class ResultItemAction implements \JsonSerializable
{
private string $label = '';
private ?Icon $icon = null;
private string $url = '';
/**
* @param non-empty-string $identifier
*/
public function __construct(private readonly string $identifier) {}
public function getIdentifier(): string
{
return $this->identifier;
}
public function setLabel(string $label): self
{
$this->label = $label;
return $this;
}
public function setIcon(?Icon $icon): self
{
$this->icon = $icon;
return $this;
}
public function setUrl(string $url): self
{
$this->url = $url;
return $this;
}
public function jsonSerialize(): array
{
return [
'identifier' => $this->identifier,
'label' => $this->label,
'icon' => [
'identifier' => $this->icon?->getIdentifier(),
'overlay' => $this->icon?->getOverlayIcon()?->getIdentifier(),
],
'url' => $this->url,
];
}
}
@@ -0,0 +1,36 @@
<?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\Search\LiveSearch\SearchDemand;
final readonly class DemandProperty
{
public function __construct(
private DemandPropertyName $name,
private mixed $value,
) {}
public function getName(): DemandPropertyName
{
return $this->name;
}
public function getValue(): mixed
{
return $this->value;
}
}
@@ -0,0 +1,27 @@
<?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\Search\LiveSearch\SearchDemand;
enum DemandPropertyName
{
case pageId;
case query;
case limit;
case offset;
case searchProviders;
}
@@ -0,0 +1,41 @@
<?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\Search\LiveSearch\SearchDemand;
/**
* @internal for internal use only, no public API
*/
final class MutableSearchDemand extends SearchDemand
{
public static function fromSearchDemand(SearchDemand $searchDemand): self
{
return new self($searchDemand->getProperties());
}
public function setProperty(DemandPropertyName $name, mixed $value): self
{
$this->demandProperties[$name->name] = new DemandProperty($name, $value);
return $this;
}
public function freeze(): SearchDemand
{
return new SearchDemand($this->demandProperties);
}
}
@@ -0,0 +1,102 @@
<?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\Search\LiveSearch\SearchDemand;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Search\LiveSearch\SearchProviderInterface;
/**
* Holds necessary data to query data from a search provider
*
* @internal may change in further iterations, do not rely on it
*/
class SearchDemand
{
public const DEFAULT_LIMIT = 20;
/**
* @var DemandProperty[] $demandProperties
*/
protected array $demandProperties = [];
/**
* @param DemandProperty[] $demandProperties
*/
final public function __construct(array $demandProperties = [])
{
$this->demandProperties = array_reduce($demandProperties, static function (array $result, DemandProperty $item) {
$result[$item->getName()->name] = $item;
return $result;
}, []);
}
public function getProperty(DemandPropertyName $demandPropertyName): ?DemandProperty
{
return $this->demandProperties[$demandPropertyName->name] ?? null;
}
/**
* @return DemandProperty[]
*/
public function getProperties(): array
{
return $this->demandProperties;
}
public function getPageId(): int
{
return (int)($this->getProperty(DemandPropertyName::pageId)?->getValue() ?? 0);
}
public function getQuery(): string
{
return $this->getProperty(DemandPropertyName::query)?->getValue() ?? '';
}
public function getLimit(): int
{
return (int)($this->getProperty(DemandPropertyName::limit)?->getValue() ?? self::DEFAULT_LIMIT);
}
public function getOffset(): int
{
return (int)($this->getProperty(DemandPropertyName::offset)?->getValue() ?? 0);
}
/**
* @return class-string<SearchProviderInterface>[]
*/
public function getSearchProviders(): array
{
return $this->getProperty(DemandPropertyName::searchProviders)?->getValue() ?? [];
}
public static function fromRequest(ServerRequestInterface $request): static
{
$demandProperties = [];
foreach (DemandPropertyName::cases() as $demandProperty) {
$demandPropertyName = $demandProperty->name;
$valueFromRequest = $request->getParsedBody()[$demandPropertyName] ?? $request->getQueryParams()[$demandPropertyName] ?? null;
if ($valueFromRequest !== null) {
$demandProperties[] = new DemandProperty($demandProperty, $valueFromRequest);
}
}
return new static($demandProperties);
}
}
@@ -0,0 +1,37 @@
<?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\Search\LiveSearch;
use TYPO3\CMS\Backend\Search\LiveSearch\SearchDemand\SearchDemand;
/**
* Interface to declare a search provider used for the backend search
*
* @internal Interface may change in further iterations, do not rely on it
*/
interface SearchProviderInterface
{
public function count(SearchDemand $searchDemand): int;
/**
* @return ResultItem[]
*/
public function find(SearchDemand $searchDemand): array;
public function getFilterLabel(): string;
}
@@ -0,0 +1,49 @@
<?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\Search\LiveSearch;
/**
* Registry that holds all registered search providers
*
* @internal
*/
final class SearchProviderRegistry
{
/**
* @var SearchProviderInterface[]
*/
private array $providers = [];
/**
* @param iterable<SearchProviderInterface> $providers
*/
public function __construct(iterable $providers)
{
foreach ($providers as $item) {
$this->providers[get_class($item)] = $item;
}
}
/**
* @return SearchProviderInterface[]
*/
public function getProviders(): array
{
return $this->providers;
}
}
@@ -0,0 +1,139 @@
<?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\Search\LiveSearch;
use Psr\EventDispatcher\EventDispatcherInterface;
use TYPO3\CMS\Backend\Search\Event\ModifyResultItemInLiveSearchEvent;
use TYPO3\CMS\Backend\Search\LiveSearch\SearchDemand\DemandPropertyName;
use TYPO3\CMS\Backend\Search\LiveSearch\SearchDemand\MutableSearchDemand;
use TYPO3\CMS\Backend\Search\LiveSearch\SearchDemand\SearchDemand;
use TYPO3\CMS\Core\Pagination\ArrayPaginator;
/**
* Repository class to ease using the search API.
*
* @internal
*/
final readonly class SearchRepository
{
public function __construct(
private EventDispatcherInterface $eventDispatcher,
private SearchProviderRegistry $searchProviderRegistry,
) {}
/**
* Returns a list of available search providers including a flag whether they are currently active.
*
* @param SearchDemand $searchDemand
* @return array<class-string, array{instance: SearchProviderInterface, isActive: bool}>
*/
public function getSearchProviderState(SearchDemand $searchDemand): array
{
$searchProviders = [];
foreach ($this->searchProviderRegistry->getProviders() as $searchProviderClassName => $searchProvider) {
$searchProviders[$searchProviderClassName] = [
'instance' => $searchProvider,
'isActive' => in_array($searchProviderClassName, $searchDemand->getSearchProviders(), true),
];
}
return $searchProviders;
}
/**
* @return SearchProviderInterface[]
*/
public function getViableSearchProviders(SearchDemand $searchDemand): array
{
return array_filter(
$this->searchProviderRegistry->getProviders(),
static fn(SearchProviderInterface $provider): bool => $searchDemand->getSearchProviders() === [] || in_array(get_class($provider), $searchDemand->getSearchProviders(), true)
);
}
public function find(SearchDemand $searchDemand): ArrayPaginator
{
$searchResults = [];
$totalCount = 0;
$mutableSearchDemand = MutableSearchDemand::fromSearchDemand($searchDemand);
$offset = $searchDemand->getOffset();
$remainingItems = $searchDemand->getLimit();
foreach ($this->getViableSearchProviders($searchDemand) as $provider) {
// Initialize remaining-items and offset for current iteration
$mutableSearchDemand
->setProperty(DemandPropertyName::limit, $remainingItems)
->setProperty(DemandPropertyName::offset, $offset);
$count = $provider->count($mutableSearchDemand->freeze());
// Total count is relevant outside of this loop:
// Paginator calculates number of pages and fills up its result set with stub-entries.
$totalCount += $count;
if ($count < $offset) {
// The number of potential results is smaller than the offset, do not query results
$offset -= $count;
continue;
}
if ($remainingItems < 1) {
continue;
}
$providerResult = $provider->find($mutableSearchDemand->freeze());
if ($providerResult !== []) {
foreach ($providerResult as $key => $resultItem) {
$modifyRecordEvent = $this->eventDispatcher->dispatch(new ModifyResultItemInLiveSearchEvent($resultItem));
$providerResult[$key] = $modifyRecordEvent->getResultItem();
}
$remainingItems -= count($providerResult);
// We have got a first usable result from the current SearchProvider here. The offset is thereby fulfilled.
// All follow-up SearchProviders must run with offset=0 to start with their first item:
// * to generate a correct item-count and
// * to provide more items if there are still open remaining-items.
$offset = 0;
$searchResults[] = $providerResult;
}
}
unset($mutableSearchDemand);
$flattenedSearchResults = array_merge([], ...$searchResults);
$resultCount = count($flattenedSearchResults);
$currentPage = (int)floor(($searchDemand->getOffset() + $searchDemand->getLimit()) / $searchDemand->getLimit());
if (ceil($totalCount / $searchDemand->getLimit()) < $currentPage) {
// Requested page does not match with the overall amount of items, reset to first page
$currentPage = 1;
}
if ($resultCount > 0) {
// The paginator expects a full result set to be able to calculate its pagination. This will have negative
// performance consequences, therefore we only consider the current result set and create stubs for the "gaps".
$paginatorItems = array_merge(
array_fill(0, $searchDemand->getOffset(), null),
$flattenedSearchResults,
array_fill(0, $totalCount - $searchDemand->getOffset() - $resultCount, null)
);
} else {
$paginatorItems = [];
}
return new ArrayPaginator($paginatorItems, $currentPage, SearchDemand::DEFAULT_LIMIT);
}
}