TYPO3 v15 dev-main snapshot ()
This commit is contained in:
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user