1<?php 2namespace TYPO3\CMS\Backend\Form\Wizard; 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 17use TYPO3\CMS\Backend\Tree\View\PageTreeView; 18use TYPO3\CMS\Backend\Utility\BackendUtility; 19use TYPO3\CMS\Core\Database\ConnectionPool; 20use TYPO3\CMS\Core\Database\Query\QueryBuilder; 21use TYPO3\CMS\Core\Database\Query\QueryHelper; 22use TYPO3\CMS\Core\Database\Query\Restriction\BackendWorkspaceRestriction; 23use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction; 24use TYPO3\CMS\Core\Imaging\Icon; 25use TYPO3\CMS\Core\Imaging\IconFactory; 26use TYPO3\CMS\Core\Localization\LanguageService; 27use TYPO3\CMS\Core\Type\Bitmask\Permission; 28use TYPO3\CMS\Core\Utility\GeneralUtility; 29use TYPO3\CMS\Core\Utility\MathUtility; 30 31/** 32 * Default implementation of a handler class for an ajax record selector. 33 * 34 * Normally other implementations should be inherited from this one. 35 * queryTable() should not be overwritten under normal circumstances. 36 */ 37class SuggestWizardDefaultReceiver 38{ 39 /** 40 * The name of the table to query 41 * 42 * @var string 43 */ 44 protected $table = ''; 45 46 /** 47 * The name of the foreign table to query (records from this table will be used for displaying instead of the ones 48 * from $table) 49 * 50 * @var string 51 */ 52 protected $mmForeignTable = ''; 53 54 /** 55 * Configuration for this selector from TSconfig 56 * 57 * @var array 58 */ 59 protected $config = []; 60 61 /** 62 * The list of pages that are allowed to perform the search for records on 63 * 64 * @var array Array of PIDs 65 */ 66 protected $allowedPages = []; 67 68 /** 69 * The maximum number of items to select. 70 * 71 * @var int 72 */ 73 protected $maxItems = 10; 74 75 /** 76 * @var array 77 */ 78 protected $params = []; 79 80 /** 81 * @var IconFactory 82 */ 83 protected $iconFactory; 84 85 /** 86 * @var QueryBuilder 87 */ 88 protected $queryBuilder; 89 90 /** 91 * The constructor of this class 92 * 93 * @param string $table The table to query 94 * @param array $config The configuration (TCA overlaid with TSconfig) to use for this selector 95 */ 96 public function __construct($table, $config) 97 { 98 $this->iconFactory = GeneralUtility::makeInstance(IconFactory::class); 99 $this->queryBuilder = $this->getQueryBuilderForTable($table); 100 $this->queryBuilder->getRestrictions() 101 ->removeAll() 102 ->add(GeneralUtility::makeInstance(DeletedRestriction::class)) 103 // if table is versionized, only get the records from the Live Workspace 104 // the overlay itself of WS-records is done below 105 ->add(GeneralUtility::makeInstance(BackendWorkspaceRestriction::class, 0)); 106 $this->table = $table; 107 $this->config = $config; 108 // get a list of all the pages that should be looked on 109 if (isset($config['pidList'])) { 110 $pageIds = GeneralUtility::intExplode(',', $config['pidList'], true); 111 $depth = (int)($config['pidDepth'] ?? 0); 112 $availablePageIds = []; 113 foreach ($pageIds as $pageId) { 114 $availablePageIds[] = $this->getAvailablePageIds($pageId, $depth); 115 } 116 $this->allowedPages = array_unique(array_merge($this->allowedPages, ...$availablePageIds)); 117 } 118 if (isset($config['maxItemsInResultList'])) { 119 $this->maxItems = $config['maxItemsInResultList']; 120 } 121 $GLOBALS['BE_USER']->initializeWebmountsForElementBrowser(); 122 if ($this->table === 'pages') { 123 $this->queryBuilder->andWhere( 124 QueryHelper::stripLogicalOperatorPrefix($GLOBALS['BE_USER']->getPagePermsClause(Permission::PAGE_SHOW)), 125 $this->queryBuilder->expr()->eq('sys_language_uid', 0) 126 ); 127 } 128 if (isset($config['addWhere'])) { 129 $this->queryBuilder->andWhere( 130 QueryHelper::stripLogicalOperatorPrefix($config['addWhere']) 131 ); 132 } 133 } 134 135 /** 136 * Queries a table for records and completely processes them 137 * 138 * Returns a two-dimensional array of almost finished records; the only need to be put into a <li>-structure 139 * 140 * If you subclass this class, you will most likely only want to overwrite the functions called from here, but not 141 * this function itself 142 * 143 * @param array $params 144 * @param int $recursionCounter The parent object 145 * @return array Array of rows or FALSE if nothing found 146 */ 147 public function queryTable(&$params, $recursionCounter = 0) 148 { 149 $maxQueryResults = 50; 150 $rows = []; 151 $this->params = &$params; 152 $start = $recursionCounter * $maxQueryResults; 153 $this->prepareSelectStatement(); 154 $this->prepareOrderByStatement(); 155 $result = $this->queryBuilder->select('*') 156 ->from($this->table) 157 ->setFirstResult($start) 158 ->setMaxResults($maxQueryResults) 159 ->execute(); 160 $allRowsCount = $this->queryBuilder 161 ->count('uid') 162 ->resetQueryPart('orderBy') 163 ->execute() 164 ->fetchColumn(0); 165 if ($allRowsCount) { 166 while ($row = $result->fetch()) { 167 // check if we already have collected the maximum number of records 168 if (count($rows) > $this->maxItems) { 169 break; 170 } 171 $this->manipulateRecord($row); 172 $this->makeWorkspaceOverlay($row); 173 // check if the user has access to the record 174 if (!$this->checkRecordAccess($row, $row['uid'])) { 175 continue; 176 } 177 $spriteIcon = $this->iconFactory->getIconForRecord($this->table, $row, Icon::SIZE_SMALL)->render(); 178 $uid = $row['t3ver_oid'] > 0 ? $row['t3ver_oid'] : $row['uid']; 179 $path = $this->getRecordPath($row, $uid); 180 if (mb_strlen($path, 'utf-8') > 30) { 181 $croppedPath = '<abbr title="' . htmlspecialchars($path) . '">' . 182 htmlspecialchars( 183 mb_substr($path, 0, 10, 'utf-8') 184 . '...' 185 . mb_substr($path, -20, null, 'utf-8') 186 ) . 187 '</abbr>'; 188 } else { 189 $croppedPath = htmlspecialchars($path); 190 } 191 $label = $this->getLabel($row); 192 $entry = [ 193 'text' => '<span class="suggest-label">' . $label . '</span><span class="suggest-uid">[' . $uid . ']</span><br /> 194 <span class="suggest-path">' . $croppedPath . '</span>', 195 'table' => $this->mmForeignTable ? $this->mmForeignTable : $this->table, 196 'label' => strip_tags($label), 197 'path' => $path, 198 'uid' => $uid, 199 'style' => '', 200 'class' => $this->config['cssClass'] ?? '', 201 'sprite' => $spriteIcon 202 ]; 203 $rows[$this->table . '_' . $uid] = $this->renderRecord($row, $entry); 204 } 205 206 // if there are less records than we need, call this function again to get more records 207 if (count($rows) < $this->maxItems && $allRowsCount >= $maxQueryResults && $recursionCounter < $this->maxItems) { 208 $tmp = self::queryTable($params, ++$recursionCounter); 209 $rows = array_merge($tmp, $rows); 210 } 211 } 212 return $rows; 213 } 214 215 /** 216 * Prepare the statement for selecting the records which will be returned to the selector. May also return some 217 * other records (e.g. from a mm-table) which will be used later on to select the real records 218 */ 219 protected function prepareSelectStatement() 220 { 221 $expressionBuilder = $this->queryBuilder->expr(); 222 $searchString = $this->params['value']; 223 if ($searchString !== '') { 224 $splitStrings = $this->splitSearchString($searchString); 225 $constraints = []; 226 foreach ($splitStrings as $splitString) { 227 $constraints[] = $this->buildConstraintBlock($splitString); 228 } 229 foreach ($constraints as $constraint) { 230 $this->queryBuilder->andWhere($expressionBuilder->andX($constraint)); 231 } 232 } 233 if (!empty($this->allowedPages)) { 234 $pidList = array_map('intval', $this->allowedPages); 235 if (!empty($pidList)) { 236 $this->queryBuilder->andWhere( 237 $expressionBuilder->in('pid', $pidList) 238 ); 239 } 240 } 241 // add an additional search condition comment 242 if (isset($this->config['searchCondition']) && $this->config['searchCondition'] !== '') { 243 $this->queryBuilder->andWhere(QueryHelper::stripLogicalOperatorPrefix($this->config['searchCondition'])); 244 } 245 } 246 247 /** 248 * Creates OR constraints for each split searchWord. 249 * 250 * @param string $searchString 251 * @return string|\TYPO3\CMS\Core\Database\Query\Expression\CompositeExpression 252 */ 253 protected function buildConstraintBlock(string $searchString) 254 { 255 $expressionBuilder = $this->queryBuilder->expr(); 256 $selectParts = $expressionBuilder->orX(); 257 if (MathUtility::canBeInterpretedAsInteger($searchString) && (int)$searchString > 0) { 258 $selectParts->add($expressionBuilder->eq('uid', (int)$searchString)); 259 } 260 $searchWholePhrase = !isset($this->config['searchWholePhrase']) || $this->config['searchWholePhrase']; 261 $likeCondition = ($searchWholePhrase ? '%' : '') . $this->queryBuilder->escapeLikeWildcards($searchString) . '%'; 262 // Search in all fields given by label or label_alt 263 $selectFieldsList = ($GLOBALS['TCA'][$this->table]['ctrl']['label'] ?? '') . ',' . ($GLOBALS['TCA'][$this->table]['ctrl']['label_alt'] ?? '') . ',' . $this->config['additionalSearchFields']; 264 $selectFields = GeneralUtility::trimExplode(',', $selectFieldsList, true); 265 $selectFields = array_unique($selectFields); 266 foreach ($selectFields as $field) { 267 $selectParts->add($expressionBuilder->like($field, $this->queryBuilder->createPositionalParameter($likeCondition))); 268 } 269 270 return $selectParts; 271 } 272 273 /** 274 * Splits the search string by space 275 * This allows searching for 'elements basic' and will find results like "elements rte basic" 276 * To search for whole phrases enclose by double-quotes: '"elements basic"', results in empty result 277 * 278 * @param string $searchString 279 * @return array 280 */ 281 protected function splitSearchString(string $searchString): array 282 { 283 return str_getcsv($searchString, ' '); 284 } 285 286 /** 287 * Get array of page ids from given page id and depth 288 * 289 * @param int $id Page id. 290 * @param int $depth Depth to go down. 291 * @return array of all page ids 292 */ 293 protected function getAvailablePageIds(int $id, int $depth = 0): array 294 { 295 if ($depth === 0) { 296 return [$id]; 297 } 298 $tree = GeneralUtility::makeInstance(PageTreeView::class); 299 $tree->init(); 300 $tree->getTree($id, $depth); 301 $tree->makeHTML = 0; 302 $tree->fieldArray = ['uid']; 303 $tree->ids[] = $id; 304 return $tree->ids; 305 } 306 307 /** 308 * Prepares the clause by which the result elements are sorted. See description of ORDER BY in 309 * SQL standard for reference. 310 */ 311 protected function prepareOrderByStatement() 312 { 313 if (empty($this->config['orderBy'])) { 314 $this->queryBuilder->addOrderBy($GLOBALS['TCA'][$this->table]['ctrl']['label']); 315 } else { 316 foreach (QueryHelper::parseOrderBy($this->config['orderBy']) as $orderPair) { 317 list($fieldName, $order) = $orderPair; 318 $this->queryBuilder->addOrderBy($fieldName, $order); 319 } 320 } 321 } 322 323 /** 324 * Manipulate a record before using it to render the selector; may be used to replace a MM-relation etc. 325 * 326 * @param array $row 327 */ 328 protected function manipulateRecord(&$row) 329 { 330 } 331 332 /** 333 * Selects whether the logged in Backend User is allowed to read a specific record 334 * 335 * @param array $row 336 * @param int $uid 337 * @return bool 338 */ 339 protected function checkRecordAccess($row, $uid) 340 { 341 $retValue = true; 342 $table = $this->mmForeignTable ?: $this->table; 343 if ($table === 'pages') { 344 if (!BackendUtility::readPageAccess($uid, $GLOBALS['BE_USER']->getPagePermsClause(Permission::PAGE_SHOW))) { 345 $retValue = false; 346 } 347 } elseif (isset($GLOBALS['TCA'][$table]['ctrl']['is_static']) && (bool)$GLOBALS['TCA'][$table]['ctrl']['is_static']) { 348 $retValue = true; 349 } else { 350 if (!is_array(BackendUtility::readPageAccess($row['pid'], $GLOBALS['BE_USER']->getPagePermsClause(Permission::PAGE_SHOW)))) { 351 $retValue = false; 352 } 353 } 354 return $retValue; 355 } 356 357 /** 358 * Overlay the given record with its workspace-version, if any 359 * 360 * @param array $row The record to get the workspace version for 361 */ 362 protected function makeWorkspaceOverlay(&$row) 363 { 364 // Check for workspace-versions 365 if ($GLOBALS['BE_USER']->workspace != 0 && $GLOBALS['TCA'][$this->table]['ctrl']['versioningWS'] == true) { 366 BackendUtility::workspaceOL($this->mmForeignTable ? $this->mmForeignTable : $this->table, $row); 367 } 368 } 369 370 /** 371 * Returns the path for a record. Is the whole path for all records except pages - for these the last part is cut 372 * off, because it contains the pagetitle itself, which would be double information 373 * 374 * The path is returned uncut, cutting has to be done by calling function. 375 * 376 * @param array $row The row 377 * @param int $uid UID of the record 378 * @return string The record-path 379 */ 380 protected function getRecordPath(&$row, $uid) 381 { 382 $titleLimit = max($this->config['maxPathTitleLength'], 0); 383 if (($this->mmForeignTable ? $this->mmForeignTable : $this->table) === 'pages') { 384 $path = BackendUtility::getRecordPath($uid, '', $titleLimit); 385 // For pages we only want the first (n-1) parts of the path, 386 // because the n-th part is the page itself 387 $path = substr($path, 0, strrpos($path, '/', -2)) . '/'; 388 } else { 389 $path = BackendUtility::getRecordPath($row['pid'], '', $titleLimit); 390 } 391 return $path; 392 } 393 394 /** 395 * Returns a label for a given record; usually only a wrapper for \TYPO3\CMS\Backend\Utility\BackendUtility::getRecordTitle 396 * 397 * @param array $row The record to get the label for 398 * @return string The label 399 */ 400 protected function getLabel($row) 401 { 402 return BackendUtility::getRecordTitle($this->mmForeignTable ? $this->mmForeignTable : $this->table, $row, true); 403 } 404 405 /** 406 * Calls a user function for rendering the page. 407 * 408 * This user function should manipulate $entry, especially $entry['text']. 409 * 410 * @param array $row The row 411 * @param array $entry The entry to render 412 * @return array The rendered entry (will be put into a <li> later on 413 */ 414 protected function renderRecord($row, $entry) 415 { 416 // Call renderlet if available (normal pages etc. usually don't have one) 417 if ($this->config['renderFunc'] != '') { 418 $params = [ 419 'table' => $this->table, 420 'uid' => $row['uid'], 421 'row' => $row, 422 'entry' => &$entry 423 ]; 424 GeneralUtility::callUserFunction($this->config['renderFunc'], $params, $this); 425 } 426 return $entry; 427 } 428 429 /** 430 * @return LanguageService 431 */ 432 protected function getLanguageService() 433 { 434 return $GLOBALS['LANG']; 435 } 436 437 /** 438 * @param string $table 439 * @return QueryBuilder 440 */ 441 protected function getQueryBuilderForTable($table) 442 { 443 return GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table); 444 } 445} 446