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