7c035a5fad750b86435cda4f87cdd3ac006eb6c3
[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) or table is hidden, skip this table
145 if (
146 (
147 !$GLOBALS['BE_USER']->check('tables_select', $tableName) &&
148 !$GLOBALS['BE_USER']->check('tables_modify', $tableName)
149 ) ||
150 (isset($value['ctrl']['hideTable']) && $value['ctrl']['hideTable'])
151 ) {
152 continue;
153 }
154 $recordArray = $this->findByTable($tableName, $pageIdList, 0, $limit);
155 $recordCount = count($recordArray);
156 if ($recordCount) {
157 $limit = $limit - $recordCount;
158 $getRecordArray[] = $recordArray;
159 if ($limit <= 0) {
160 break;
161 }
162 }
163 }
164 return $getRecordArray;
165 }
166
167 /**
168 * Find records by given table name.
169 *
170 * @param string $tableName Database table name
171 * @param string $pageIdList Comma separated list of page IDs
172 * @param int $firstResult
173 * @param int $maxResults
174 * @return array Records found in the database matching the searchQuery
175 * @see getRecordArray()
176 * @see makeQuerySearchByTable()
177 * @see extractSearchableFieldsFromTable()
178 */
179 protected function findByTable($tableName, $pageIdList, $firstResult, $maxResults)
180 {
181 $fieldsToSearchWithin = $this->extractSearchableFieldsFromTable($tableName);
182 $getRecordArray = [];
183 if (!empty($fieldsToSearchWithin)) {
184 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
185 ->getQueryBuilderForTable($tableName);
186
187 $queryBuilder
188 ->select('*')
189 ->from($tableName)
190 ->where(
191 $queryBuilder->expr()->in(
192 'pid',
193 $queryBuilder->createNamedParameter($pageIdList, Connection::PARAM_INT_ARRAY)
194 ),
195 $this->makeQuerySearchByTable($queryBuilder, $tableName, $fieldsToSearchWithin)
196 )
197 ->setFirstResult($firstResult)
198 ->setMaxResults($maxResults);
199
200 if ($tableName === 'pages' && $this->userPermissions) {
201 $queryBuilder->andWhere($this->userPermissions);
202 }
203
204 $orderBy = $GLOBALS['TCA'][$tableName]['ctrl']['sortby'] ?: $GLOBALS['TCA'][$tableName]['ctrl']['default_sortby'];
205 foreach (QueryHelper::parseOrderBy((string)$orderBy) as $orderPair) {
206 list($fieldName, $order) = $orderPair;
207 $queryBuilder->addOrderBy($fieldName, $order);
208 }
209
210 $getRecordArray = $this->getRecordArray($queryBuilder, $tableName);
211 }
212
213 return $getRecordArray;
214 }
215
216 /**
217 * Process the Database operation to get the search result.
218 *
219 * @param QueryBuilder $queryBuilder Database table name
220 * @param string $tableName
221 * @return array
222 * @see getTitleFromCurrentRow()
223 * @see getEditLink()
224 */
225 protected function getRecordArray($queryBuilder, $tableName)
226 {
227 $collect = [];
228 $result = $queryBuilder->execute();
229 $iconFactory = GeneralUtility::makeInstance(IconFactory::class);
230 while ($row = $result->fetch()) {
231 $title = 'id=' . $row['uid'] . ', pid=' . $row['pid'];
232 $collect[] = [
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 $returnUrl = BackendUtility::getModuleUrl('web_list', ['id' => $row['pid']]);
266 $editLink = BackendUtility::getModuleUrl('record_edit', [
267 'edit[' . $tableName . '][' . $row['uid'] . ']' => 'edit',
268 'returnUrl' => $returnUrl
269 ]);
270 }
271 return $editLink;
272 }
273
274 /**
275 * Retrieve the record name
276 *
277 * @param string $tableName Record table name
278 * @return string
279 */
280 protected function getTitleOfCurrentRecordType($tableName)
281 {
282 return $GLOBALS['LANG']->sL($GLOBALS['TCA'][$tableName]['ctrl']['title']);
283 }
284
285 /**
286 * Crops a title string to a limited length and if it really was cropped,
287 * wrap it in a <span title="...">|</span>,
288 * which offers a tooltip with the original title when moving mouse over it.
289 *
290 * @param string $title The title string to be cropped
291 * @param int $titleLength Crop title after this length - if not set, BE_USER->uc['titleLen'] is used
292 * @return string The processed title string, wrapped in <span title="...">|</span> if cropped
293 */
294 public function getRecordTitlePrep($title, $titleLength = 0)
295 {
296 // If $titleLength is not a valid positive integer, use BE_USER->uc['titleLen']:
297 if (!$titleLength || !MathUtility::canBeInterpretedAsInteger($titleLength) || $titleLength < 0) {
298 $titleLength = $GLOBALS['BE_USER']->uc['titleLen'];
299 }
300 return htmlspecialchars(GeneralUtility::fixed_lgd_cs($title, $titleLength));
301 }
302
303 /**
304 * Build the MySql where clause by table.
305 *
306 * @param QueryBuilder $queryBuilder
307 * @param string $tableName Record table name
308 * @param array $fieldsToSearchWithin User right based visible fields where we can search within.
309 * @return CompositeExpression
310 */
311 protected function makeQuerySearchByTable(QueryBuilder &$queryBuilder, $tableName, array $fieldsToSearchWithin)
312 {
313 $constraints = [];
314
315 // If the search string is a simple integer, assemble an equality comparison
316 if (MathUtility::canBeInterpretedAsInteger($this->queryString)) {
317 foreach ($fieldsToSearchWithin as $fieldName) {
318 if ($fieldName !== 'uid'
319 && $fieldName !== 'pid'
320 && !isset($GLOBALS['TCA'][$tableName]['columns'][$fieldName])
321 ) {
322 continue;
323 }
324 $fieldConfig = $GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config'];
325 $fieldType = $fieldConfig['type'];
326 $evalRules = $fieldConfig['eval'] ?: '';
327
328 // Assemble the search condition only if the field is an integer, or is uid or pid
329 if ($fieldName === 'uid'
330 || $fieldName === 'pid'
331 || ($fieldType === 'input' && $evalRules && GeneralUtility::inList($evalRules, 'int'))
332 ) {
333 $constraints[] = $queryBuilder->expr()->eq(
334 $fieldName,
335 $queryBuilder->createNamedParameter($this->queryString, \PDO::PARAM_INT)
336 );
337 } elseif ($fieldType === 'text'
338 || $fieldType === 'flex'
339 || ($fieldType === 'input' && (!$evalRules || !preg_match('/date|time|int/', $evalRules)))
340 ) {
341 // Otherwise and if the field makes sense to be searched, assemble a like condition
342 $constraints[] = $constraints[] = $queryBuilder->expr()->like(
343 $fieldName,
344 $queryBuilder->createNamedParameter(
345 '%' . $queryBuilder->escapeLikeWildcards((int)$this->queryString) . '%',
346 \PDO::PARAM_STR
347 )
348 );
349 }
350 }
351 } else {
352 $like = '%' . $queryBuilder->escapeLikeWildcards($this->queryString) . '%';
353 foreach ($fieldsToSearchWithin as $fieldName) {
354 if (!isset($GLOBALS['TCA'][$tableName]['columns'][$fieldName])) {
355 continue;
356 }
357 $fieldConfig = &$GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config'];
358 $fieldType = $fieldConfig['type'];
359 $evalRules = $fieldConfig['eval'] ?: '';
360
361 // Check whether search should be case-sensitive or not
362 $searchConstraint = $queryBuilder->expr()->andX(
363 $queryBuilder->expr()->comparison(
364 'LOWER(' . $queryBuilder->quoteIdentifier($fieldName) . ')',
365 'LIKE',
366 $queryBuilder->createNamedParameter(strtolower($like), \PDO::PARAM_STR)
367 )
368 );
369
370 if (is_array($fieldConfig['search'])) {
371 if (in_array('case', $fieldConfig['search'], true)) {
372 // Replace case insensitive default constraint
373 $searchConstraint = $queryBuilder->expr()->andX(
374 $queryBuilder->expr()->like(
375 $fieldName,
376 $queryBuilder->createNamedParameter($like, \PDO::PARAM_STR)
377 )
378 );
379 }
380 // Apply additional condition, if any
381 if ($fieldConfig['search']['andWhere']) {
382 $searchConstraint->add(
383 QueryHelper::stripLogicalOperatorPrefix($fieldConfig['search']['andWhere'])
384 );
385 }
386 }
387 // Assemble the search condition only if the field makes sense to be searched
388 if ($fieldType === 'text'
389 || $fieldType === 'flex'
390 || $fieldType === 'input' && (!$evalRules || !preg_match('/date|time|int/', $evalRules))
391 ) {
392 if ($searchConstraint->count() !== 0) {
393 $constraints[] = $searchConstraint;
394 }
395 }
396 }
397 }
398
399 // If no search field conditions have been build ensure no results are returned
400 if (empty($constraints)) {
401 return '0=1';
402 }
403
404 return $queryBuilder->expr()->orX(...$constraints);
405 }
406
407 /**
408 * Get all fields from given table where we can search for.
409 *
410 * @param string $tableName Name of the table for which to get the searchable fields
411 * @return array
412 */
413 protected function extractSearchableFieldsFromTable($tableName)
414 {
415 // Get the list of fields to search in from the TCA, if any
416 if (isset($GLOBALS['TCA'][$tableName]['ctrl']['searchFields'])) {
417 $fieldListArray = GeneralUtility::trimExplode(',', $GLOBALS['TCA'][$tableName]['ctrl']['searchFields'], true);
418 } else {
419 $fieldListArray = [];
420 }
421 // Add special fields
422 if ($GLOBALS['BE_USER']->isAdmin()) {
423 $fieldListArray[] = 'uid';
424 $fieldListArray[] = 'pid';
425 }
426 return $fieldListArray;
427 }
428
429 /**
430 * Setter for limit value.
431 *
432 * @param int $limitCount
433 */
434 public function setLimitCount($limitCount)
435 {
436 $limit = MathUtility::convertToPositiveInteger($limitCount);
437 if ($limit > 0) {
438 $this->limitCount = $limit;
439 }
440 }
441
442 /**
443 * Setter for start count value.
444 *
445 * @param int $startCount
446 */
447 public function setStartCount($startCount)
448 {
449 $this->startCount = MathUtility::convertToPositiveInteger($startCount);
450 }
451
452 /**
453 * Setter for the search query string.
454 *
455 * @param string $queryString
456 */
457 public function setQueryString($queryString)
458 {
459 $this->queryString = $queryString;
460 }
461
462 /**
463 * Creates an instance of \TYPO3\CMS\Backend\Tree\View\PageTreeView which will select a
464 * page tree to $depth and return the object. In that object we will find the ids of the tree.
465 *
466 * @param int $id Page id.
467 * @param int $depth Depth to go down.
468 * @return string Comma separated list of uids
469 */
470 protected function getAvailablePageIds($id, $depth)
471 {
472 $tree = GeneralUtility::makeInstance(\TYPO3\CMS\Backend\Tree\View\PageTreeView::class);
473 $tree->init('AND ' . $this->userPermissions);
474 $tree->makeHTML = 0;
475 $tree->fieldArray = ['uid', 'php_tree_stop'];
476 if ($depth) {
477 $tree->getTree($id, $depth, '');
478 }
479 $tree->ids[] = $id;
480 // add workspace pid - workspace permissions are taken into account by where clause later
481 $tree->ids[] = -1;
482 $idList = implode(',', $tree->ids);
483 return $idList;
484 }
485 }