[BUGFIX] Fix language synchronization for inline relations with inline relations
[Packages/TYPO3.CMS.git] / typo3 / sysext / backend / Classes / Search / LiveSearch / LiveSearch.php
1 <?php
2 namespace TYPO3\CMS\Backend\Search\LiveSearch;
3
4 /*
5 * This file is part of the TYPO3 CMS project.
6 *
7 * It is free software; you can redistribute it and/or modify it under
8 * the terms of the GNU General Public License, either version 2
9 * of the License, or any later version.
10 *
11 * For the full copyright and license information, please read the
12 * LICENSE.txt file that was distributed with this source code.
13 *
14 * The TYPO3 project - inspiring people to share!
15 */
16
17 use TYPO3\CMS\Backend\Routing\UriBuilder;
18 use TYPO3\CMS\Backend\Utility\BackendUtility;
19 use TYPO3\CMS\Core\Database\Connection;
20 use TYPO3\CMS\Core\Database\ConnectionPool;
21 use TYPO3\CMS\Core\Database\Query\Expression\CompositeExpression;
22 use TYPO3\CMS\Core\Database\Query\QueryBuilder;
23 use TYPO3\CMS\Core\Database\Query\QueryHelper;
24 use TYPO3\CMS\Core\Database\Query\Restriction\EndTimeRestriction;
25 use TYPO3\CMS\Core\Database\Query\Restriction\HiddenRestriction;
26 use TYPO3\CMS\Core\Database\Query\Restriction\StartTimeRestriction;
27 use TYPO3\CMS\Core\Imaging\Icon;
28 use TYPO3\CMS\Core\Imaging\IconFactory;
29 use TYPO3\CMS\Core\Localization\LanguageService;
30 use TYPO3\CMS\Core\Type\Bitmask\Permission;
31 use TYPO3\CMS\Core\Utility\GeneralUtility;
32 use TYPO3\CMS\Core\Utility\MathUtility;
33
34 /**
35 * Class for handling backend live search.
36 * @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
37 */
38 class LiveSearch
39 {
40 /**
41 * @var string
42 */
43 const PAGE_JUMP_TABLE = 'pages';
44
45 /**
46 * @var int
47 */
48 const RECURSIVE_PAGE_LEVEL = 99;
49
50 /**
51 * @var int
52 */
53 const GROUP_TITLE_MAX_LENGTH = 15;
54
55 /**
56 * @var int
57 */
58 const RECORD_TITLE_MAX_LENGTH = 28;
59
60 /**
61 * @var string
62 */
63 private $queryString = '';
64
65 /**
66 * @var int
67 */
68 private $startCount = 0;
69
70 /**
71 * @var int
72 */
73 private $limitCount = 5;
74
75 /**
76 * @var string
77 */
78 protected $userPermissions = '';
79
80 /**
81 * @var QueryParser
82 */
83 protected $queryParser;
84
85 /**
86 * Initialize access settings
87 */
88 public function __construct()
89 {
90 $this->userPermissions = $GLOBALS['BE_USER']->getPagePermsClause(Permission::PAGE_SHOW);
91 $this->queryParser = GeneralUtility::makeInstance(QueryParser::class);
92 }
93
94 /**
95 * Find records from database based on the given $searchQuery.
96 *
97 * @param string $searchQuery
98 * @return array Result list of database search.
99 */
100 public function find($searchQuery)
101 {
102 $recordArray = [];
103 $pageList = [];
104 $mounts = $GLOBALS['BE_USER']->returnWebmounts();
105 foreach ($mounts as $pageId) {
106 $pageList[] = $this->getAvailablePageIds($pageId, self::RECURSIVE_PAGE_LEVEL);
107 }
108 $pageIdList = array_unique(explode(',', implode(',', $pageList)));
109 unset($pageList);
110 if ($this->queryParser->isValidCommand($searchQuery)) {
111 $this->setQueryString($this->queryParser->getSearchQueryValue($searchQuery));
112 $tableName = $this->queryParser->getTableNameFromCommand($searchQuery);
113 if ($tableName) {
114 $recordArray[] = $this->findByTable($tableName, $pageIdList, $this->startCount, $this->limitCount);
115 }
116 } else {
117 $this->setQueryString($searchQuery);
118 $recordArray = $this->findByGlobalTableList($pageIdList);
119 }
120 return $recordArray;
121 }
122
123 /**
124 * Find records from all registered TCA table & column values.
125 *
126 * @param array $pageIdList Comma separated list of page IDs
127 * @return array Records found in the database matching the searchQuery
128 */
129 protected function findByGlobalTableList($pageIdList)
130 {
131 $limit = $this->limitCount;
132 $getRecordArray = [];
133 foreach ($GLOBALS['TCA'] as $tableName => $value) {
134 // if no access for the table (read or write) or table is hidden, skip this table
135 if (
136 (isset($value['ctrl']['hideTable']) && $value['ctrl']['hideTable'])
137 ||
138 (
139 !$GLOBALS['BE_USER']->check('tables_select', $tableName) &&
140 !$GLOBALS['BE_USER']->check('tables_modify', $tableName)
141 )
142 ) {
143 continue;
144 }
145 $recordArray = $this->findByTable($tableName, $pageIdList, 0, $limit);
146 $recordCount = count($recordArray);
147 if ($recordCount) {
148 $limit -= $recordCount;
149 $getRecordArray[] = $recordArray;
150 if ($limit <= 0) {
151 break;
152 }
153 }
154 }
155 return $getRecordArray;
156 }
157
158 /**
159 * Find records by given table name.
160 *
161 * @param string $tableName Database table name
162 * @param array $pageIdList Comma separated list of page IDs
163 * @param int $firstResult
164 * @param int $maxResults
165 * @return array Records found in the database matching the searchQuery
166 * @see getRecordArray()
167 * @see makeQuerySearchByTable()
168 * @see extractSearchableFieldsFromTable()
169 */
170 protected function findByTable($tableName, $pageIdList, $firstResult, $maxResults)
171 {
172 $fieldsToSearchWithin = $this->extractSearchableFieldsFromTable($tableName);
173 $getRecordArray = [];
174 if (!empty($fieldsToSearchWithin)) {
175 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
176 ->getQueryBuilderForTable($tableName);
177 $queryBuilder->getRestrictions()
178 ->removeByType(HiddenRestriction::class)
179 ->removeByType(StartTimeRestriction::class)
180 ->removeByType(EndTimeRestriction::class);
181
182 $queryBuilder
183 ->select('*')
184 ->from($tableName)
185 ->where(
186 $queryBuilder->expr()->in(
187 'pid',
188 $queryBuilder->createNamedParameter($pageIdList, Connection::PARAM_INT_ARRAY)
189 ),
190 $this->makeQuerySearchByTable($queryBuilder, $tableName, $fieldsToSearchWithin)
191 )
192 ->setFirstResult($firstResult)
193 ->setMaxResults($maxResults);
194
195 if ($tableName === 'pages' && $this->userPermissions) {
196 $queryBuilder->andWhere($this->userPermissions);
197 }
198
199 $orderBy = $GLOBALS['TCA'][$tableName]['ctrl']['sortby'] ?: $GLOBALS['TCA'][$tableName]['ctrl']['default_sortby'];
200 foreach (QueryHelper::parseOrderBy((string)$orderBy) as $orderPair) {
201 [$fieldName, $order] = $orderPair;
202 $queryBuilder->addOrderBy($fieldName, $order);
203 }
204
205 $getRecordArray = $this->getRecordArray($queryBuilder, $tableName);
206 }
207
208 return $getRecordArray;
209 }
210
211 /**
212 * Process the Database operation to get the search result.
213 *
214 * @param QueryBuilder $queryBuilder Database table name
215 * @param string $tableName
216 * @return array
217 * @see getTitleFromCurrentRow()
218 * @see getEditLink()
219 */
220 protected function getRecordArray($queryBuilder, $tableName)
221 {
222 $collect = [];
223 $result = $queryBuilder->execute();
224 $iconFactory = GeneralUtility::makeInstance(IconFactory::class);
225 while ($row = $result->fetch()) {
226 BackendUtility::workspaceOL($tableName, $row);
227 if (!is_array($row)) {
228 continue;
229 }
230 $onlineUid = $row['t3ver_oid'] ?: $row['uid'];
231 $title = 'id=' . $row['uid'] . ', pid=' . $row['pid'];
232 $collect[$onlineUid] = [
233 'id' => $tableName . ':' . $row['uid'],
234 'pageId' => $tableName === 'pages' ? $row['uid'] : $row['pid'],
235 'typeLabel' => htmlspecialchars($this->getTitleOfCurrentRecordType($tableName)),
236 'iconHTML' => '<span title="' . htmlspecialchars($title) . '">' . $iconFactory->getIconForRecord($tableName, $row, Icon::SIZE_SMALL)->render() . '</span>',
237 'title' => htmlspecialchars(BackendUtility::getRecordTitle($tableName, $row)),
238 'editLink' => htmlspecialchars($this->getEditLink($tableName, $row))
239 ];
240 }
241 return $collect;
242 }
243
244 /**
245 * Build a backend edit link based on given record.
246 *
247 * @param string $tableName Record table name
248 * @param array $row Current record row from database.
249 * @return string Link to open an edit window for record.
250 * @see \TYPO3\CMS\Backend\Utility\BackendUtility::readPageAccess()
251 */
252 protected function getEditLink($tableName, $row)
253 {
254 $pageInfo = BackendUtility::readPageAccess($row['pid'], $this->userPermissions);
255 $calcPerms = $GLOBALS['BE_USER']->calcPerms($pageInfo);
256 $editLink = '';
257 if ($tableName === 'pages') {
258 $localCalcPerms = $GLOBALS['BE_USER']->calcPerms(BackendUtility::getRecord('pages', $row['uid']));
259 $permsEdit = $localCalcPerms & Permission::PAGE_EDIT;
260 } else {
261 $permsEdit = $calcPerms & Permission::CONTENT_EDIT;
262 }
263 // "Edit" link - Only if permissions to edit the page-record of the content of the parent page ($this->id)
264 if ($permsEdit) {
265 $uriBuilder = GeneralUtility::makeInstance(UriBuilder::class);
266 $returnUrl = (string)$uriBuilder->buildUriFromRoute('web_list', ['id' => $row['pid']]);
267 $editLink = (string)$uriBuilder->buildUriFromRoute('record_edit', [
268 'edit[' . $tableName . '][' . $row['uid'] . ']' => 'edit',
269 'returnUrl' => $returnUrl
270 ]);
271 }
272 return $editLink;
273 }
274
275 /**
276 * Retrieve the record name
277 *
278 * @param string $tableName Record table name
279 * @return string
280 */
281 protected function getTitleOfCurrentRecordType($tableName)
282 {
283 return $this->getLanguageService()->sL($GLOBALS['TCA'][$tableName]['ctrl']['title']);
284 }
285
286 /**
287 * Crops a title string to a limited length and if it really was cropped,
288 * wrap it in a <span title="...">|</span>,
289 * which offers a tooltip with the original title when moving mouse over it.
290 *
291 * @param string $title The title string to be cropped
292 * @param int $titleLength Crop title after this length - if not set, BE_USER->uc['titleLen'] is used
293 * @return string The processed title string, wrapped in <span title="...">|</span> if cropped
294 */
295 public function getRecordTitlePrep($title, $titleLength = 0)
296 {
297 // If $titleLength is not a valid positive integer, use BE_USER->uc['titleLen']:
298 if (!$titleLength || !MathUtility::canBeInterpretedAsInteger($titleLength) || $titleLength < 0) {
299 $titleLength = $GLOBALS['BE_USER']->uc['titleLen'];
300 }
301 return htmlspecialchars(GeneralUtility::fixed_lgd_cs($title, $titleLength));
302 }
303
304 /**
305 * Build the MySql where clause by table.
306 *
307 * @param QueryBuilder $queryBuilder
308 * @param string $tableName Record table name
309 * @param array $fieldsToSearchWithin User right based visible fields where we can search within.
310 * @return CompositeExpression
311 */
312 protected function makeQuerySearchByTable(QueryBuilder &$queryBuilder, $tableName, array $fieldsToSearchWithin)
313 {
314 $constraints = [];
315
316 // If the search string is a simple integer, assemble an equality comparison
317 if (MathUtility::canBeInterpretedAsInteger($this->queryString)) {
318 foreach ($fieldsToSearchWithin as $fieldName) {
319 if ($fieldName !== 'uid'
320 && $fieldName !== 'pid'
321 && !isset($GLOBALS['TCA'][$tableName]['columns'][$fieldName])
322 ) {
323 continue;
324 }
325 $fieldConfig = $GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config'];
326 $fieldType = $fieldConfig['type'];
327 $evalRules = $fieldConfig['eval'] ?: '';
328
329 // Assemble the search condition only if the field is an integer, or is uid or pid
330 if ($fieldName === 'uid'
331 || $fieldName === 'pid'
332 || ($fieldType === 'input' && $evalRules && GeneralUtility::inList($evalRules, 'int'))
333 ) {
334 $constraints[] = $queryBuilder->expr()->eq(
335 $fieldName,
336 $queryBuilder->createNamedParameter($this->queryString, \PDO::PARAM_INT)
337 );
338 } elseif ($fieldType === 'text'
339 || $fieldType === 'flex'
340 || ($fieldType === 'input' && (!$evalRules || !preg_match('/\b(?:date|time|int)\b/', $evalRules)))
341 ) {
342 // Otherwise and if the field makes sense to be searched, assemble a like condition
343 $constraints[] = $constraints[] = $queryBuilder->expr()->like(
344 $fieldName,
345 $queryBuilder->createNamedParameter(
346 '%' . $queryBuilder->escapeLikeWildcards((int)$this->queryString) . '%',
347 \PDO::PARAM_STR
348 )
349 );
350 }
351 }
352 } else {
353 $like = '%' . $queryBuilder->escapeLikeWildcards($this->queryString) . '%';
354 foreach ($fieldsToSearchWithin as $fieldName) {
355 if (!isset($GLOBALS['TCA'][$tableName]['columns'][$fieldName])) {
356 continue;
357 }
358 $fieldConfig = &$GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config'];
359 $fieldType = $fieldConfig['type'];
360 $evalRules = $fieldConfig['eval'] ?: '';
361
362 // Check whether search should be case-sensitive or not
363 $searchConstraint = $queryBuilder->expr()->andX(
364 $queryBuilder->expr()->comparison(
365 'LOWER(' . $queryBuilder->quoteIdentifier($fieldName) . ')',
366 'LIKE',
367 $queryBuilder->createNamedParameter(mb_strtolower($like), \PDO::PARAM_STR)
368 )
369 );
370
371 if (is_array($fieldConfig['search'])) {
372 if (in_array('case', $fieldConfig['search'], true)) {
373 // Replace case insensitive default constraint
374 $searchConstraint = $queryBuilder->expr()->andX(
375 $queryBuilder->expr()->like(
376 $fieldName,
377 $queryBuilder->createNamedParameter($like, \PDO::PARAM_STR)
378 )
379 );
380 }
381 // Apply additional condition, if any
382 if ($fieldConfig['search']['andWhere']) {
383 $searchConstraint->add(
384 QueryHelper::stripLogicalOperatorPrefix($fieldConfig['search']['andWhere'])
385 );
386 }
387 }
388 // Assemble the search condition only if the field makes sense to be searched
389 if ($fieldType === 'text'
390 || $fieldType === 'flex'
391 || ($fieldType === 'input' && (!$evalRules || !preg_match('/\b(?:date|time|int)\b/', $evalRules)))
392 ) {
393 if ($searchConstraint->count() !== 0) {
394 $constraints[] = $searchConstraint;
395 }
396 }
397 }
398 }
399
400 // If no search field conditions have been build ensure no results are returned
401 if (empty($constraints)) {
402 return '0=1';
403 }
404
405 return $queryBuilder->expr()->orX(...$constraints);
406 }
407
408 /**
409 * Get all fields from given table where we can search for.
410 *
411 * @param string $tableName Name of the table for which to get the searchable fields
412 * @return array
413 */
414 protected function extractSearchableFieldsFromTable($tableName)
415 {
416 // Get the list of fields to search in from the TCA, if any
417 if (isset($GLOBALS['TCA'][$tableName]['ctrl']['searchFields'])) {
418 $fieldListArray = GeneralUtility::trimExplode(',', $GLOBALS['TCA'][$tableName]['ctrl']['searchFields'], true);
419 } else {
420 $fieldListArray = [];
421 }
422 // Add special fields
423 if ($GLOBALS['BE_USER']->isAdmin()) {
424 $fieldListArray[] = 'uid';
425 $fieldListArray[] = 'pid';
426 }
427 return $fieldListArray;
428 }
429
430 /**
431 * Setter for limit value.
432 *
433 * @param int $limitCount
434 */
435 public function setLimitCount($limitCount)
436 {
437 $limit = MathUtility::convertToPositiveInteger($limitCount);
438 if ($limit > 0) {
439 $this->limitCount = $limit;
440 }
441 }
442
443 /**
444 * Setter for start count value.
445 *
446 * @param int $startCount
447 */
448 public function setStartCount($startCount)
449 {
450 $this->startCount = MathUtility::convertToPositiveInteger($startCount);
451 }
452
453 /**
454 * Setter for the search query string.
455 *
456 * @param string $queryString
457 */
458 public function setQueryString($queryString)
459 {
460 $this->queryString = $queryString;
461 }
462
463 /**
464 * Creates an instance of \TYPO3\CMS\Backend\Tree\View\PageTreeView which will select a
465 * page tree to $depth and return the object. In that object we will find the ids of the tree.
466 *
467 * @param int $id Page id.
468 * @param int $depth Depth to go down.
469 * @return string Comma separated list of uids
470 */
471 protected function getAvailablePageIds($id, $depth)
472 {
473 $tree = GeneralUtility::makeInstance(\TYPO3\CMS\Backend\Tree\View\PageTreeView::class);
474 $tree->init('AND ' . $this->userPermissions);
475 $tree->makeHTML = 0;
476 $tree->fieldArray = ['uid', 'php_tree_stop'];
477 if ($depth) {
478 $tree->getTree($id, $depth, '');
479 }
480 $tree->ids[] = $id;
481 // add workspace pid - workspace permissions are taken into account by where clause later
482 $tree->ids[] = -1;
483 return implode(',', $tree->ids);
484 }
485
486 /**
487 * @return LanguageService|null
488 */
489 protected function getLanguageService(): ?LanguageService
490 {
491 return $GLOBALS['LANG'] ?? null;
492 }
493 }