1<?php 2 3/* 4 * This file is part of the TYPO3 CMS project. 5 * 6 * It is free software; you can redistribute it and/or modify it under 7 * the terms of the GNU General Public License, either version 2 8 * of the License, or any later version. 9 * 10 * For the full copyright and license information, please read the 11 * LICENSE.txt file that was distributed with this source code. 12 * 13 * The TYPO3 project - inspiring people to share! 14 */ 15 16namespace TYPO3\CMS\IndexedSearch\Controller; 17 18use TYPO3\CMS\Core\Configuration\ExtensionConfiguration; 19use TYPO3\CMS\Core\Context\Context; 20use TYPO3\CMS\Core\Database\Connection; 21use TYPO3\CMS\Core\Database\ConnectionPool; 22use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer; 23use TYPO3\CMS\Core\Domain\Repository\PageRepository; 24use TYPO3\CMS\Core\Exception\Page\RootLineException; 25use TYPO3\CMS\Core\Exception\SiteNotFoundException; 26use TYPO3\CMS\Core\Html\HtmlParser; 27use TYPO3\CMS\Core\Site\SiteFinder; 28use TYPO3\CMS\Core\Type\File\ImageInfo; 29use TYPO3\CMS\Core\TypoScript\TypoScriptService; 30use TYPO3\CMS\Core\Utility\GeneralUtility; 31use TYPO3\CMS\Core\Utility\IpAnonymizationUtility; 32use TYPO3\CMS\Core\Utility\MathUtility; 33use TYPO3\CMS\Core\Utility\PathUtility; 34use TYPO3\CMS\Core\Utility\RootlineUtility; 35use TYPO3\CMS\Extbase\Annotation as Extbase; 36use TYPO3\CMS\Extbase\Mvc\Controller\ActionController; 37use TYPO3\CMS\Extbase\Utility\LocalizationUtility; 38use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer; 39use TYPO3\CMS\IndexedSearch\Domain\Repository\IndexSearchRepository; 40use TYPO3\CMS\IndexedSearch\Lexer; 41use TYPO3\CMS\IndexedSearch\Utility\IndexedSearchUtility; 42 43/** 44 * Index search frontend 45 * 46 * Creates a search form for indexed search. Indexing must be enabled 47 * for this to make sense. 48 * @internal This class is a specific controller implementation and is not considered part of the Public TYPO3 API. 49 */ 50class SearchController extends ActionController 51{ 52 /** 53 * previously known as $this->piVars['sword'] 54 * 55 * @var string 56 */ 57 protected $sword = ''; 58 59 /** 60 * @var array 61 */ 62 protected $searchWords = []; 63 64 /** 65 * @var array 66 */ 67 protected $searchData; 68 69 /** 70 * This is the id of the site root. 71 * This value may be a comma separated list of integer (prepared for this) 72 * Root-page PIDs to search in (rl0 field where clause, see initialize() function) 73 * 74 * If this value is set to less than zero (eg. -1) searching will happen 75 * in ALL of the page tree with no regard to branches at all. 76 * @var int|string 77 */ 78 protected $searchRootPageIdList = 0; 79 80 /** 81 * @var int 82 */ 83 protected $defaultResultNumber = 10; 84 85 /** 86 * @var int[] 87 */ 88 protected $availableResultsNumbers = []; 89 90 /** 91 * Search repository 92 * 93 * @var \TYPO3\CMS\IndexedSearch\Domain\Repository\IndexSearchRepository 94 */ 95 protected $searchRepository; 96 97 /** 98 * Lexer object 99 * 100 * @var \TYPO3\CMS\IndexedSearch\Lexer 101 */ 102 protected $lexerObj; 103 104 /** 105 * External parser objects 106 * @var array 107 */ 108 protected $externalParsers = []; 109 110 /** 111 * Will hold the first row in result - used to calculate relative hit-ratings. 112 * 113 * @var array 114 */ 115 protected $firstRow = []; 116 117 /** 118 * sys_domain records 119 * 120 * @var array 121 */ 122 protected $domainRecords = []; 123 124 /** 125 * Required fe_groups memberships for display of a result. 126 * 127 * @var array 128 */ 129 protected $requiredFrontendUsergroups = []; 130 131 /** 132 * Page tree sections for search result. 133 * 134 * @var array 135 */ 136 protected $resultSections = []; 137 138 /** 139 * Caching of page path 140 * 141 * @var array 142 */ 143 protected $pathCache = []; 144 145 /** 146 * Storage of icons 147 * 148 * @var array 149 */ 150 protected $iconFileNameCache = []; 151 152 /** 153 * Indexer configuration, coming from TYPO3's system configuration for EXT:indexed_search 154 * 155 * @var array 156 */ 157 protected $indexerConfig = []; 158 159 /** 160 * Flag whether metaphone search should be enabled 161 * 162 * @var bool 163 */ 164 protected $enableMetaphoneSearch = false; 165 166 /** 167 * @var \TYPO3\CMS\Core\TypoScript\TypoScriptService 168 */ 169 protected $typoScriptService; 170 171 /** 172 * @param \TYPO3\CMS\Core\TypoScript\TypoScriptService $typoScriptService 173 */ 174 public function injectTypoScriptService(TypoScriptService $typoScriptService) 175 { 176 $this->typoScriptService = $typoScriptService; 177 } 178 179 /** 180 * sets up all necessary object for searching 181 * 182 * @param array $searchData The incoming search parameters 183 * @return array Search parameters 184 */ 185 public function initialize($searchData = []) 186 { 187 if (!is_array($searchData)) { 188 $searchData = []; 189 } 190 191 // check if TypoScript is loaded 192 if (!isset($this->settings['results'])) { 193 $this->redirect('noTypoScript'); 194 } 195 196 // Sets availableResultsNumbers - has to be called before request settings are read to avoid DoS attack 197 $this->availableResultsNumbers = array_filter(GeneralUtility::intExplode(',', $this->settings['blind']['numberOfResults'])); 198 199 // Sets default result number if at least one availableResultsNumbers exists 200 if (isset($this->availableResultsNumbers[0])) { 201 $this->defaultResultNumber = $this->availableResultsNumbers[0]; 202 } 203 204 $this->loadSettings(); 205 206 // setting default values 207 if (is_array($this->settings['defaultOptions'])) { 208 $searchData = array_merge($this->settings['defaultOptions'], $searchData); 209 } 210 // if "languageUid" was set to "current", take the current site language 211 if (($searchData['languageUid'] ?? '') === 'current') { 212 $searchData['languageUid'] = GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('language', 'id', 0); 213 } 214 215 // Indexer configuration from Extension Manager interface: 216 $this->indexerConfig = GeneralUtility::makeInstance(ExtensionConfiguration::class)->get('indexed_search'); 217 $this->enableMetaphoneSearch = (bool)$this->indexerConfig['enableMetaphoneSearch']; 218 $this->initializeExternalParsers(); 219 // If "_sections" is set, this value overrides any existing value. 220 if ($searchData['_sections']) { 221 $searchData['sections'] = $searchData['_sections']; 222 } 223 // If "_sections" is set, this value overrides any existing value. 224 if ($searchData['_freeIndexUid'] !== '' && $searchData['_freeIndexUid'] !== '_') { 225 $searchData['freeIndexUid'] = $searchData['_freeIndexUid']; 226 } 227 $searchData['numberOfResults'] = $this->getNumberOfResults($searchData['numberOfResults']); 228 // This gets the search-words into the $searchWordArray 229 $this->setSword($searchData['sword']); 230 // Add previous search words to current 231 if ($searchData['sword_prev_include'] && $searchData['sword_prev']) { 232 $this->setSword(trim($searchData['sword_prev']) . ' ' . $this->getSword()); 233 } 234 // This is the id of the site root. 235 // This value may be a commalist of integer (prepared for this) 236 $this->searchRootPageIdList = (int)$GLOBALS['TSFE']->config['rootLine'][0]['uid']; 237 // Setting the list of root PIDs for the search. Notice, these page IDs MUST 238 // have a TypoScript template with root flag on them! Basically this list is used 239 // to select on the "rl0" field and page ids are registered as "rl0" only if 240 // a TypoScript template record with root flag is there. 241 // This happens AFTER the use of $this->searchRootPageIdList above because 242 // the above will then fetch the menu for the CURRENT site - regardless 243 // of this kind of searching here. Thus a general search will lookup in 244 // the WHOLE database while a specific section search will take the current sections. 245 if ($this->settings['rootPidList']) { 246 $this->searchRootPageIdList = implode(',', GeneralUtility::intExplode(',', $this->settings['rootPidList'])); 247 } 248 $this->searchRepository = GeneralUtility::makeInstance(IndexSearchRepository::class); 249 $this->searchRepository->initialize($this->settings, $searchData, $this->externalParsers, $this->searchRootPageIdList); 250 $this->searchData = $searchData; 251 // $this->searchData is used in $this->getSearchWords 252 $this->searchWords = $this->getSearchWords($searchData['defaultOperand']); 253 // Calling hook for modification of initialized content 254 if ($hookObj = $this->hookRequest('initialize_postProc')) { 255 $hookObj->initialize_postProc(); 256 } 257 return $searchData; 258 } 259 260 /** 261 * Performs the search, the display and writing stats 262 * 263 * @param array $search the search parameters, an associative array 264 * @Extbase\IgnoreValidation("search") 265 */ 266 public function searchAction($search = []) 267 { 268 $searchData = $this->initialize($search); 269 // Find free index uid: 270 $freeIndexUid = $searchData['freeIndexUid']; 271 if ($freeIndexUid == -2) { 272 $freeIndexUid = $this->settings['defaultFreeIndexUidList']; 273 } elseif (!isset($searchData['freeIndexUid'])) { 274 // index configuration is disabled 275 $freeIndexUid = -1; 276 } 277 278 if (!empty($searchData['extendedSearch'])) { 279 $this->view->assignMultiple($this->processExtendedSearchParameters()); 280 } 281 282 $indexCfgs = GeneralUtility::intExplode(',', $freeIndexUid); 283 $resultsets = []; 284 foreach ($indexCfgs as $freeIndexUid) { 285 // Get result rows 286 $tstamp1 = IndexedSearchUtility::milliseconds(); 287 if ($hookObj = $this->hookRequest('getResultRows')) { 288 $resultData = $hookObj->getResultRows($this->searchWords, $freeIndexUid); 289 } else { 290 $resultData = $this->searchRepository->doSearch($this->searchWords, $freeIndexUid); 291 } 292 // Display search results 293 $tstamp2 = IndexedSearchUtility::milliseconds(); 294 if ($hookObj = $this->hookRequest('getDisplayResults')) { 295 $resultsets[$freeIndexUid] = $hookObj->getDisplayResults($this->searchWords, $resultData, $freeIndexUid); 296 } else { 297 $resultsets[$freeIndexUid] = $this->getDisplayResults($this->searchWords, $resultData, $freeIndexUid); 298 } 299 $tstamp3 = IndexedSearchUtility::milliseconds(); 300 // Create header if we are searching more than one indexing configuration 301 if (count($indexCfgs) > 1) { 302 if ($freeIndexUid > 0) { 303 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) 304 ->getQueryBuilderForTable('index_config'); 305 $queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class)); 306 $indexCfgRec = $queryBuilder 307 ->select('title') 308 ->from('index_config') 309 ->where( 310 $queryBuilder->expr()->eq( 311 'uid', 312 $queryBuilder->createNamedParameter($freeIndexUid, \PDO::PARAM_INT) 313 ) 314 ) 315 ->execute() 316 ->fetch(); 317 $categoryTitle = LocalizationUtility::translate('indexingConfigurationHeader.' . $freeIndexUid, 'IndexedSearch'); 318 $categoryTitle = $categoryTitle ?: $indexCfgRec['title']; 319 } else { 320 $categoryTitle = LocalizationUtility::translate('indexingConfigurationHeader.' . $freeIndexUid, 'IndexedSearch'); 321 } 322 $resultsets[$freeIndexUid]['categoryTitle'] = $categoryTitle; 323 } 324 // Write search statistics 325 $this->writeSearchStat($searchData, $this->searchWords, $resultData['count'], [$tstamp1, $tstamp2, $tstamp3]); 326 } 327 $this->view->assign('resultsets', $resultsets); 328 $this->view->assign('searchParams', $searchData); 329 $this->view->assign('searchWords', $this->searchWords); 330 } 331 332 /**************************************** 333 * functions to make the result rows and result sets 334 * ready for the output 335 ***************************************/ 336 /** 337 * Compiles the HTML display of the incoming array of result rows. 338 * 339 * @param array $searchWords Search words array (for display of text describing what was searched for) 340 * @param array $resultData Array with result rows, count, first row. 341 * @param int $freeIndexUid Pointing to which indexing configuration you want to search in. -1 means no filtering. 0 means only regular indexed content. 342 * @return array 343 */ 344 protected function getDisplayResults($searchWords, $resultData, $freeIndexUid = -1) 345 { 346 $result = [ 347 'count' => $resultData['count'], 348 'searchWords' => $searchWords 349 ]; 350 // Perform display of result rows array 351 if ($resultData) { 352 // Set first selected row (for calculation of ranking later) 353 $this->firstRow = $resultData['firstRow']; 354 // Result display here 355 $result['rows'] = $this->compileResultRows($resultData['resultRows'], $freeIndexUid); 356 $result['affectedSections'] = $this->resultSections; 357 // Browsing box 358 if ($resultData['count']) { 359 // could we get this in the view? 360 if ($this->searchData['group'] === 'sections' && $freeIndexUid <= 0) { 361 $resultSectionsCount = count($this->resultSections); 362 $result['sectionText'] = sprintf(LocalizationUtility::translate('result.' . ($resultSectionsCount > 1 ? 'inNsections' : 'inNsection'), 'IndexedSearch') ?? '', $resultSectionsCount); 363 } 364 } 365 } 366 // Print a message telling which words in which sections we searched for 367 if (strpos($this->searchData['sections'], 'rl') === 0) { 368 $result['searchedInSectionInfo'] = (LocalizationUtility::translate('result.inSection', 'IndexedSearch') ?? '') . ' "' . $this->getPathFromPageId((int)substr($this->searchData['sections'], 4)) . '"'; 369 } 370 371 if ($hookObj = $this->hookRequest('getDisplayResults_postProc')) { 372 $result = $hookObj->getDisplayResults_postProc($result); 373 } 374 375 return $result; 376 } 377 378 /** 379 * Takes the array with resultrows as input and returns the result-HTML-code 380 * Takes the "group" var into account: Makes a "section" or "flat" display. 381 * 382 * @param array $resultRows Result rows 383 * @param int $freeIndexUid Pointing to which indexing configuration you want to search in. -1 means no filtering. 0 means only regular indexed content. 384 * @return array the result rows with additional information 385 */ 386 protected function compileResultRows($resultRows, $freeIndexUid = -1) 387 { 388 $finalResultRows = []; 389 // Transfer result rows to new variable, 390 // performing some mapping of sub-results etc. 391 $newResultRows = []; 392 foreach ($resultRows as $row) { 393 $id = md5($row['phash_grouping']); 394 if (is_array($newResultRows[$id])) { 395 // swapping: 396 if (!$newResultRows[$id]['show_resume'] && $row['show_resume']) { 397 // Remove old 398 $subrows = $newResultRows[$id]['_sub']; 399 unset($newResultRows[$id]['_sub']); 400 $subrows[] = $newResultRows[$id]; 401 // Insert new: 402 $newResultRows[$id] = $row; 403 $newResultRows[$id]['_sub'] = $subrows; 404 } else { 405 $newResultRows[$id]['_sub'][] = $row; 406 } 407 } else { 408 $newResultRows[$id] = $row; 409 } 410 } 411 $resultRows = $newResultRows; 412 $this->resultSections = []; 413 if ($freeIndexUid <= 0 && $this->searchData['group'] === 'sections') { 414 $rl2flag = strpos($this->searchData['sections'], 'rl') === 0; 415 $sections = []; 416 foreach ($resultRows as $row) { 417 $id = $row['rl0'] . '-' . $row['rl1'] . ($rl2flag ? '-' . $row['rl2'] : ''); 418 $sections[$id][] = $row; 419 } 420 $this->resultSections = []; 421 foreach ($sections as $id => $resultRows) { 422 $rlParts = explode('-', $id); 423 if ($rlParts[2]) { 424 $theId = $rlParts[2]; 425 $theRLid = 'rl2_' . $rlParts[2]; 426 } elseif ($rlParts[1]) { 427 $theId = $rlParts[1]; 428 $theRLid = 'rl1_' . $rlParts[1]; 429 } else { 430 $theId = $rlParts[0]; 431 $theRLid = '0'; 432 } 433 $sectionName = $this->getPathFromPageId((int)$theId); 434 $sectionName = ltrim($sectionName, '/'); 435 if (!trim($sectionName)) { 436 $sectionTitleLinked = LocalizationUtility::translate('result.unnamedSection', 'IndexedSearch') . ':'; 437 } else { 438 $onclick = 'document.forms[\'tx_indexedsearch\'][\'tx_indexedsearch_pi2[search][_sections]\'].value=' . GeneralUtility::quoteJSvalue($theRLid) . ';document.forms[\'tx_indexedsearch\'].submit();return false;'; 439 $sectionTitleLinked = '<a href="#" onclick="' . htmlspecialchars($onclick) . '">' . $sectionName . ':</a>'; 440 } 441 $resultRowsCount = count($resultRows); 442 $this->resultSections[$id] = [$sectionName, $resultRowsCount]; 443 // Add section header 444 $finalResultRows[] = [ 445 'isSectionHeader' => true, 446 'numResultRows' => $resultRowsCount, 447 'sectionId' => $id, 448 'sectionTitle' => $sectionTitleLinked 449 ]; 450 // Render result rows 451 foreach ($resultRows as $row) { 452 $finalResultRows[] = $this->compileSingleResultRow($row); 453 } 454 } 455 } else { 456 // flat mode or no sections at all 457 foreach ($resultRows as $row) { 458 $finalResultRows[] = $this->compileSingleResultRow($row); 459 } 460 } 461 return $finalResultRows; 462 } 463 464 /** 465 * This prints a single result row, including a recursive call for subrows. 466 * 467 * @param array $row Search result row 468 * @param int $headerOnly 1=Display only header (for sub-rows!), 2=nothing at all 469 * @return array the result row with additional information 470 */ 471 protected function compileSingleResultRow($row, $headerOnly = 0) 472 { 473 $specRowConf = $this->getSpecialConfigurationForResultRow($row); 474 $resultData = $row; 475 $resultData['headerOnly'] = $headerOnly; 476 $resultData['CSSsuffix'] = $specRowConf['CSSsuffix'] ? '-' . $specRowConf['CSSsuffix'] : ''; 477 if ($this->multiplePagesType($row['item_type'])) { 478 $dat = json_decode($row['static_page_arguments'], true); 479 $pp = explode('-', $dat['key']); 480 if ($pp[0] != $pp[1]) { 481 $resultData['titleaddition'] = ', ' . LocalizationUtility::translate('result.page', 'IndexedSearch') . ' ' . $dat['key']; 482 } else { 483 $resultData['titleaddition'] = ', ' . LocalizationUtility::translate('result.pages', 'IndexedSearch') . ' ' . $pp[0]; 484 } 485 } 486 $title = $resultData['item_title'] . $resultData['titleaddition']; 487 $title = GeneralUtility::fixed_lgd_cs($title, $this->settings['results.']['titleCropAfter'], $this->settings['results.']['titleCropSignifier']); 488 // If external media, link to the media-file instead. 489 if ($row['item_type']) { 490 if ($row['show_resume']) { 491 // Can link directly. 492 $targetAttribute = ''; 493 if ($GLOBALS['TSFE']->config['config']['fileTarget']) { 494 $targetAttribute = ' target="' . htmlspecialchars($GLOBALS['TSFE']->config['config']['fileTarget']) . '"'; 495 } 496 $title = '<a href="' . htmlspecialchars($row['data_filename']) . '"' . $targetAttribute . '>' . htmlspecialchars($title) . '</a>'; 497 } else { 498 // Suspicious, so linking to page instead... 499 $copiedRow = $row; 500 unset($copiedRow['static_page_arguments']); 501 $title = $this->linkPageATagWrap( 502 $title, 503 $this->linkPage($row['page_id'], $copiedRow) 504 ); 505 } 506 } else { 507 // Else the page: 508 // Prepare search words for markup in content: 509 $markUpSwParams = []; 510 if ($this->settings['forwardSearchWordsInResultLink']['_typoScriptNodeValue']) { 511 if ($this->settings['forwardSearchWordsInResultLink']['no_cache']) { 512 $markUpSwParams = ['no_cache' => 1]; 513 } 514 foreach ($this->searchWords as $d) { 515 $markUpSwParams['sword_list'][] = $d['sword']; 516 } 517 } 518 $title = $this->linkPageATagWrap( 519 $title, 520 $this->linkPage($row['data_page_id'], $row, $markUpSwParams) 521 ); 522 } 523 $resultData['title'] = $title; 524 $resultData['icon'] = $this->makeItemTypeIcon($row['item_type'], '', $specRowConf); 525 $resultData['rating'] = $this->makeRating($row); 526 $resultData['description'] = $this->makeDescription( 527 $row, 528 (bool)!($this->searchData['extResume'] && !$headerOnly), 529 $this->settings['results.']['summaryCropAfter'] 530 ); 531 $resultData['language'] = $this->makeLanguageIndication($row); 532 $resultData['size'] = GeneralUtility::formatSize($row['item_size']); 533 $resultData['created'] = $row['item_crdate']; 534 $resultData['modified'] = $row['item_mtime']; 535 $pI = parse_url($row['data_filename']); 536 if ($pI['scheme']) { 537 $targetAttribute = ''; 538 if ($GLOBALS['TSFE']->config['config']['fileTarget']) { 539 $targetAttribute = ' target="' . htmlspecialchars($GLOBALS['TSFE']->config['config']['fileTarget']) . '"'; 540 } 541 $resultData['pathTitle'] = $row['data_filename']; 542 $resultData['pathUri'] = $row['data_filename']; 543 $resultData['path'] = '<a href="' . htmlspecialchars($row['data_filename']) . '"' . $targetAttribute . '>' . htmlspecialchars($row['data_filename']) . '</a>'; 544 } else { 545 $pathId = $row['data_page_id'] ?: $row['page_id']; 546 $pathMP = $row['data_page_id'] ? $row['data_page_mp'] : ''; 547 $pathStr = $this->getPathFromPageId($pathId, $pathMP); 548 $pathLinkData = $this->linkPage( 549 $pathId, 550 [ 551 'data_page_type' => $row['data_page_type'], 552 'data_page_mp' => $pathMP, 553 'sys_language_uid' => $row['sys_language_uid'], 554 'static_page_arguments' => $row['static_page_arguments'] 555 ] 556 ); 557 558 $resultData['pathTitle'] = $pathStr; 559 $resultData['pathUri'] = $pathLinkData['uri']; 560 $resultData['path'] = $this->linkPageATagWrap($pathStr, $pathLinkData); 561 562 // check if the access is restricted 563 if (is_array($this->requiredFrontendUsergroups[$pathId]) && !empty($this->requiredFrontendUsergroups[$pathId])) { 564 $lockedIcon = GeneralUtility::getFileAbsFileName('EXT:indexed_search/Resources/Public/Icons/FileTypes/locked.gif'); 565 $lockedIcon = PathUtility::getAbsoluteWebPath($lockedIcon); 566 $resultData['access'] = '<img src="' . htmlspecialchars($lockedIcon) . '"' 567 . ' width="12" height="15" vspace="5" title="' 568 . sprintf(LocalizationUtility::translate('result.memberGroups', 'IndexedSearch') ?? '', implode(',', array_unique($this->requiredFrontendUsergroups[$pathId]))) 569 . '" alt="" />'; 570 } 571 } 572 // If there are subrows (eg. subpages in a PDF-file or if a duplicate page 573 // is selected due to user-login (phash_grouping)) 574 if (is_array($row['_sub'])) { 575 $resultData['subresults'] = []; 576 if ($this->multiplePagesType($row['item_type'])) { 577 $resultData['subresults']['header'] = LocalizationUtility::translate('result.otherMatching', 'IndexedSearch'); 578 foreach ($row['_sub'] as $subRow) { 579 $resultData['subresults']['items'][] = $this->compileSingleResultRow($subRow, 1); 580 } 581 } else { 582 $resultData['subresults']['header'] = LocalizationUtility::translate('result.otherMatching', 'IndexedSearch'); 583 $resultData['subresults']['info'] = LocalizationUtility::translate('result.otherPageAsWell', 'IndexedSearch'); 584 } 585 } 586 return $resultData; 587 } 588 589 /** 590 * Returns configuration from TypoScript for result row based 591 * on ID / location in page tree! 592 * 593 * @param array $row Result row 594 * @return array Configuration array 595 */ 596 protected function getSpecialConfigurationForResultRow($row) 597 { 598 $pathId = $row['data_page_id'] ?: $row['page_id']; 599 $pathMP = $row['data_page_id'] ? $row['data_page_mp'] : ''; 600 $specConf = $this->settings['specialConfiguration']['0']; 601 try { 602 $rl = GeneralUtility::makeInstance(RootlineUtility::class, $pathId, $pathMP)->get(); 603 foreach ($rl as $dat) { 604 if (is_array($this->settings['specialConfiguration'][$dat['uid']])) { 605 $specConf = $this->settings['specialConfiguration'][$dat['uid']]; 606 $specConf['_pid'] = $dat['uid']; 607 break; 608 } 609 } 610 } catch (RootLineException $e) { 611 // do nothing 612 } 613 return $specConf; 614 } 615 616 /** 617 * Return the rating-HTML code for the result row. This makes use of the $this->firstRow 618 * 619 * @param array $row Result row array 620 * @return string String showing ranking value 621 * @todo can this be a ViewHelper? 622 */ 623 protected function makeRating($row) 624 { 625 $default = ' '; 626 switch ((string)$this->searchData['sortOrder']) { 627 case 'rank_count': 628 return $row['order_val'] . ' ' . LocalizationUtility::translate('result.ratingMatches', 'IndexedSearch'); 629 case 'rank_first': 630 return ceil(MathUtility::forceIntegerInRange(255 - $row['order_val'], 1, 255) / 255 * 100) . '%'; 631 case 'rank_flag': 632 if ($this->firstRow['order_val2']) { 633 // (3 MSB bit, 224 is highest value of order_val1 currently) 634 $base = $row['order_val1'] * 256; 635 // 15-3 MSB = 12 636 $freqNumber = $row['order_val2'] / $this->firstRow['order_val2'] * 2 ** 12; 637 $total = MathUtility::forceIntegerInRange($base + $freqNumber, 0, 32767); 638 return ceil(log($total) / log(32767) * 100) . '%'; 639 } 640 return $default; 641 case 'rank_freq': 642 $max = 10000; 643 $total = MathUtility::forceIntegerInRange($row['order_val'], 0, $max); 644 return ceil(log($total) / log($max) * 100) . '%'; 645 case 'crdate': 646 return $GLOBALS['TSFE']->cObj->calcAge($GLOBALS['EXEC_TIME'] - $row['item_crdate'], 0); 647 case 'mtime': 648 return $GLOBALS['TSFE']->cObj->calcAge($GLOBALS['EXEC_TIME'] - $row['item_mtime'], 0); 649 default: 650 return $default; 651 } 652 } 653 654 /** 655 * Returns the HTML code for language indication. 656 * 657 * @param array $row Result row 658 * @return string HTML code for result row. 659 */ 660 protected function makeLanguageIndication($row) 661 { 662 $output = ' '; 663 // If search result is a TYPO3 page: 664 if ((string)$row['item_type'] === '0') { 665 // If TypoScript is used to render the flag: 666 if (is_array($this->settings['flagRendering'])) { 667 /** @var \TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer $cObj */ 668 $cObj = GeneralUtility::makeInstance(ContentObjectRenderer::class); 669 $cObj->setCurrentVal($row['sys_language_uid']); 670 $typoScriptArray = $this->typoScriptService->convertPlainArrayToTypoScriptArray($this->settings['flagRendering']); 671 $output = $cObj->cObjGetSingle($this->settings['flagRendering']['_typoScriptNodeValue'], $typoScriptArray); 672 } 673 } 674 return $output; 675 } 676 677 /** 678 * Return icon for file extension 679 * 680 * @param string $imageType File extension / item type 681 * @param string $alt Title attribute value in icon. 682 * @param array $specRowConf TypoScript configuration specifically for search result. 683 * @return string HTML <img> tag for icon 684 */ 685 public function makeItemTypeIcon($imageType, $alt, $specRowConf) 686 { 687 // Build compound key if item type is 0, iconRendering is not used 688 // and specialConfiguration.[pid].pageIcon was set in TS 689 if ($imageType === '0' && $specRowConf['_pid'] && is_array($specRowConf['pageIcon']) && !is_array($this->settings['iconRendering'])) { 690 $imageType .= ':' . $specRowConf['_pid']; 691 } 692 if (!isset($this->iconFileNameCache[$imageType])) { 693 $this->iconFileNameCache[$imageType] = ''; 694 // If TypoScript is used to render the icon: 695 if (is_array($this->settings['iconRendering'])) { 696 /** @var \TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer $cObj */ 697 $cObj = GeneralUtility::makeInstance(ContentObjectRenderer::class); 698 $cObj->setCurrentVal($imageType); 699 $typoScriptArray = $this->typoScriptService->convertPlainArrayToTypoScriptArray($this->settings['iconRendering']); 700 $this->iconFileNameCache[$imageType] = $cObj->cObjGetSingle($this->settings['iconRendering']['_typoScriptNodeValue'], $typoScriptArray); 701 } else { 702 // Default creation / finding of icon: 703 $icon = ''; 704 if ($imageType === '0' || strpos($imageType, '0:') === 0) { 705 if (is_array($specRowConf['pageIcon'])) { 706 $this->iconFileNameCache[$imageType] = $GLOBALS['TSFE']->cObj->cObjGetSingle('IMAGE', $specRowConf['pageIcon']); 707 } else { 708 $icon = 'EXT:indexed_search/Resources/Public/Icons/FileTypes/pages.gif'; 709 } 710 } elseif ($this->externalParsers[$imageType]) { 711 $icon = $this->externalParsers[$imageType]->getIcon($imageType); 712 } 713 if ($icon) { 714 $fullPath = GeneralUtility::getFileAbsFileName($icon); 715 if ($fullPath) { 716 $imageInfo = GeneralUtility::makeInstance(ImageInfo::class, $fullPath); 717 $iconPath = PathUtility::stripPathSitePrefix($fullPath); 718 $this->iconFileNameCache[$imageType] = $imageInfo->getWidth() 719 ? '<img src="' . $iconPath 720 . '" width="' . $imageInfo->getWidth() 721 . '" height="' . $imageInfo->getHeight() 722 . '" title="' . htmlspecialchars($alt) . '" alt="" />' 723 : ''; 724 } 725 } 726 } 727 } 728 return $this->iconFileNameCache[$imageType]; 729 } 730 731 /** 732 * Returns the resume for the search-result. 733 * 734 * @param array $row Search result row 735 * @param bool $noMarkup If noMarkup is FALSE, then the index_fulltext table is used to select the content of the page, split it with regex to display the search words in the text. 736 * @param int $length String length 737 * @return string HTML string 738 * @todo overwork this 739 */ 740 protected function makeDescription($row, $noMarkup = false, $length = 180) 741 { 742 $markedSW = ''; 743 $outputStr = ''; 744 if ($row['show_resume']) { 745 if (!$noMarkup) { 746 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('index_fulltext'); 747 $ftdrow = $queryBuilder 748 ->select('*') 749 ->from('index_fulltext') 750 ->where( 751 $queryBuilder->expr()->eq( 752 'phash', 753 $queryBuilder->createNamedParameter($row['phash'], \PDO::PARAM_INT) 754 ) 755 ) 756 ->execute() 757 ->fetch(); 758 if ($ftdrow !== false) { 759 // Cut HTTP references after some length 760 $content = preg_replace('/(http:\\/\\/[^ ]{' . $this->settings['results.']['hrefInSummaryCropAfter'] . '})([^ ]+)/i', '$1...', $ftdrow['fulltextdata']); 761 $markedSW = $this->markupSWpartsOfString($content); 762 } 763 } 764 if (!trim($markedSW)) { 765 $outputStr = GeneralUtility::fixed_lgd_cs($row['item_description'], $length, $this->settings['results.']['summaryCropSignifier']); 766 $outputStr = htmlspecialchars($outputStr); 767 } 768 $output = $outputStr ?: $markedSW; 769 } else { 770 $output = '<span class="noResume">' . LocalizationUtility::translate('result.noResume', 'IndexedSearch') . '</span>'; 771 } 772 return $output; 773 } 774 775 /** 776 * Marks up the search words from $this->searchWords in the $str with a color. 777 * 778 * @param string $str Text in which to find and mark up search words. This text is assumed to be UTF-8 like the search words internally is. 779 * @return string Processed content 780 */ 781 protected function markupSWpartsOfString($str) 782 { 783 $htmlParser = GeneralUtility::makeInstance(HtmlParser::class); 784 // Init: 785 $str = str_replace(' ', ' ', $htmlParser->bidir_htmlspecialchars($str, -1)); 786 $str = preg_replace('/\\s\\s+/', ' ', $str); 787 $swForReg = []; 788 // Prepare search words for regex: 789 foreach ($this->searchWords as $d) { 790 $swForReg[] = preg_quote($d['sword'], '/'); 791 } 792 $regExString = '(' . implode('|', $swForReg) . ')'; 793 // Split and combine: 794 $parts = preg_split('/' . $regExString . '/i', ' ' . $str . ' ', 20000, PREG_SPLIT_DELIM_CAPTURE); 795 $parts = $parts ?: []; 796 // Constants: 797 $summaryMax = $this->settings['results.']['markupSW_summaryMax']; 798 $postPreLgd = (int)$this->settings['results.']['markupSW_postPreLgd']; 799 $postPreLgd_offset = (int)$this->settings['results.']['markupSW_postPreLgd_offset']; 800 $divider = $this->settings['results.']['markupSW_divider']; 801 $occurrences = (count($parts) - 1) / 2; 802 if ($occurrences) { 803 $postPreLgd = MathUtility::forceIntegerInRange($summaryMax / $occurrences, $postPreLgd, $summaryMax / 2); 804 } 805 // Variable: 806 $summaryLgd = 0; 807 $output = []; 808 // Shorten in-between strings: 809 foreach ($parts as $k => $strP) { 810 if ($k % 2 == 0) { 811 // Find length of the summary part: 812 $strLen = mb_strlen($parts[$k], 'utf-8'); 813 $output[$k] = $parts[$k]; 814 // Possibly shorten string: 815 if (!$k) { 816 // First entry at all (only cropped on the frontside) 817 if ($strLen > $postPreLgd) { 818 $output[$k] = $divider . preg_replace('/^[^[:space:]]+[[:space:]]/', '', GeneralUtility::fixed_lgd_cs($parts[$k], -($postPreLgd - $postPreLgd_offset))); 819 } 820 } elseif ($summaryLgd > $summaryMax || !isset($parts[$k + 1])) { 821 // In case summary length is exceed OR if there are no more entries at all: 822 if ($strLen > $postPreLgd) { 823 $output[$k] = preg_replace('/[[:space:]][^[:space:]]+$/', '', GeneralUtility::fixed_lgd_cs( 824 $parts[$k], 825 $postPreLgd - $postPreLgd_offset 826 )) . $divider; 827 } 828 } else { 829 if ($strLen > $postPreLgd * 2) { 830 $output[$k] = preg_replace('/[[:space:]][^[:space:]]+$/', '', GeneralUtility::fixed_lgd_cs( 831 $parts[$k], 832 $postPreLgd - $postPreLgd_offset 833 )) . $divider . preg_replace('/^[^[:space:]]+[[:space:]]/', '', GeneralUtility::fixed_lgd_cs($parts[$k], -($postPreLgd - $postPreLgd_offset))); 834 } 835 } 836 $summaryLgd += mb_strlen($output[$k], 'utf-8'); 837 // Protect output: 838 $output[$k] = htmlspecialchars($output[$k]); 839 // If summary lgd is exceed, break the process: 840 if ($summaryLgd > $summaryMax) { 841 break; 842 } 843 } else { 844 $summaryLgd += mb_strlen($strP, 'utf-8'); 845 $output[$k] = '<strong class="tx-indexedsearch-redMarkup">' . htmlspecialchars($parts[$k]) . '</strong>'; 846 } 847 } 848 // Return result: 849 return implode('', $output); 850 } 851 852 /** 853 * Write statistics information to database for the search operation if there was at least one search word. 854 * 855 * @param array $searchParams search params 856 * @param array $searchWords Search Word array 857 * @param int $count Number of hits 858 * @param array $pt Milliseconds the search took (start time DB query + end time DB query + end time to compile results) 859 */ 860 protected function writeSearchStat($searchParams, $searchWords, $count, $pt) 861 { 862 $searchWord = $this->getSword(); 863 if (empty($searchWord) && empty($searchWords)) { 864 return; 865 } 866 867 $ipAddress = ''; 868 try { 869 $ipMask = isset($this->indexerConfig['trackIpInStatistic']) ? (int)$this->indexerConfig['trackIpInStatistic'] : 2; 870 $ipAddress = IpAnonymizationUtility::anonymizeIp(GeneralUtility::getIndpEnv('REMOTE_ADDR'), $ipMask); 871 } catch (\Exception $e) { 872 } 873 $insertFields = [ 874 'searchstring' => mb_substr($searchWord, 0, 255), 875 'searchoptions' => serialize([$searchParams, $searchWords, $pt]), 876 'feuser_id' => (int)$GLOBALS['TSFE']->fe_user->user['uid'], 877 // cookie as set or retrieved. If people has cookies disabled this will vary all the time 878 'cookie' => $GLOBALS['TSFE']->fe_user->id, 879 // Remote IP address 880 'IP' => $ipAddress, 881 // Number of hits on the search 882 'hits' => (int)$count, 883 // Time stamp 884 'tstamp' => $GLOBALS['EXEC_TIME'] 885 ]; 886 $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('index_search_stat'); 887 $connection->insert( 888 'index_stat_search', 889 $insertFields, 890 ['searchoptions' => Connection::PARAM_LOB] 891 ); 892 $newId = $connection->lastInsertId('index_stat_search'); 893 if ($newId) { 894 $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('index_stat_word'); 895 foreach ($searchWords as $val) { 896 $insertFields = [ 897 'word' => mb_substr($val['sword'], 0, 50), 898 'index_stat_search_id' => $newId, 899 // Time stamp 900 'tstamp' => $GLOBALS['EXEC_TIME'], 901 // search page id for indexed search stats 902 'pageid' => $GLOBALS['TSFE']->id 903 ]; 904 $connection->insert('index_stat_word', $insertFields); 905 } 906 } 907 } 908 909 /** 910 * Splits the search word input into an array where each word is represented by an array with key "sword" 911 * holding the search word and key "oper" holding the SQL operator (eg. AND, OR) 912 * 913 * Only words with 2 or more characters are accepted 914 * Max 200 chars total 915 * Space is used to split words, "" can be used search for a whole string 916 * AND, OR and NOT are prefix words, overruling the default operator 917 * +/|/- equals AND, OR and NOT as operators. 918 * All search words are converted to lowercase. 919 * 920 * $defOp is the default operator. 1=OR, 0=AND 921 * 922 * @param bool $defaultOperator If TRUE, the default operator will be OR, not AND 923 * @return array Search words if any found 924 */ 925 protected function getSearchWords($defaultOperator) 926 { 927 // Shorten search-word string to max 200 bytes - shortening the string here is only a run-away feature! 928 $searchWords = mb_substr($this->getSword(), 0, 200); 929 // Convert to UTF-8 + conv. entities (was also converted during indexing!) 930 if ($GLOBALS['TSFE']->metaCharset && $GLOBALS['TSFE']->metaCharset !== 'utf-8') { 931 $searchWords = mb_convert_encoding($searchWords, 'utf-8', $GLOBALS['TSFE']->metaCharset); 932 $searchWords = html_entity_decode($searchWords); 933 } 934 $sWordArray = false; 935 if ($hookObj = $this->hookRequest('getSearchWords')) { 936 $sWordArray = $hookObj->getSearchWords_splitSWords($searchWords, $defaultOperator); 937 } else { 938 // sentence 939 if ($this->searchData['searchType'] == 20) { 940 $sWordArray = [ 941 [ 942 'sword' => trim($searchWords), 943 'oper' => 'AND' 944 ] 945 ]; 946 } else { 947 // case-sensitive. Defines the words, which will be 948 // operators between words 949 $operatorTranslateTable = [ 950 ['+', 'AND'], 951 ['|', 'OR'], 952 ['-', 'AND NOT'], 953 // Add operators for various languages 954 // Converts the operators to lowercase 955 [mb_strtolower(LocalizationUtility::translate('localizedOperandAnd', 'IndexedSearch') ?? '', 'utf-8'), 'AND'], 956 [mb_strtolower(LocalizationUtility::translate('localizedOperandOr', 'IndexedSearch') ?? '', 'utf-8'), 'OR'], 957 [mb_strtolower(LocalizationUtility::translate('localizedOperandNot', 'IndexedSearch') ?? '', 'utf-8'), 'AND NOT'] 958 ]; 959 $swordArray = IndexedSearchUtility::getExplodedSearchString($searchWords, $defaultOperator == 1 ? 'OR' : 'AND', $operatorTranslateTable); 960 if (is_array($swordArray)) { 961 $sWordArray = $this->procSearchWordsByLexer($swordArray); 962 } 963 } 964 } 965 return $sWordArray; 966 } 967 968 /** 969 * Post-process the search word array so it will match the words that was indexed (including case-folding if any) 970 * If any words are splitted into multiple words (eg. CJK will be!) the operator of the main word will remain. 971 * 972 * @param array $searchWords Search word array 973 * @return array Search word array, processed through lexer 974 */ 975 protected function procSearchWordsByLexer($searchWords) 976 { 977 $newSearchWords = []; 978 // Init lexer (used to post-processing of search words) 979 $lexerObjectClassName = $GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['indexed_search']['lexer'] ?: Lexer::class; 980 $this->lexerObj = GeneralUtility::makeInstance($lexerObjectClassName); 981 // Traverse the search word array 982 foreach ($searchWords as $wordDef) { 983 // No space in word (otherwise it might be a sentence in quotes like "there is"). 984 if (strpos($wordDef['sword'], ' ') === false) { 985 // Split the search word by lexer: 986 $res = $this->lexerObj->split2Words($wordDef['sword']); 987 // Traverse lexer result and add all words again: 988 foreach ($res as $word) { 989 $newSearchWords[] = [ 990 'sword' => $word, 991 'oper' => $wordDef['oper'] 992 ]; 993 } 994 } else { 995 $newSearchWords[] = $wordDef; 996 } 997 } 998 return $newSearchWords; 999 } 1000 1001 /** 1002 * Sort options about the search form 1003 * 1004 * @param array $search The search data / params 1005 * @Extbase\IgnoreValidation("search") 1006 */ 1007 public function formAction($search = []) 1008 { 1009 $searchData = $this->initialize($search); 1010 // Adding search field value 1011 $this->view->assign('sword', $this->getSword()); 1012 // Extended search 1013 if (!empty($searchData['extendedSearch'])) { 1014 $this->view->assignMultiple($this->processExtendedSearchParameters()); 1015 } 1016 $this->view->assign('searchParams', $searchData); 1017 } 1018 1019 /** 1020 * TypoScript was not loaded 1021 */ 1022 public function noTypoScriptAction() 1023 { 1024 } 1025 1026 /**************************************** 1027 * building together the available options for every dropdown 1028 ***************************************/ 1029 /** 1030 * get the values for the "type" selector 1031 * 1032 * @return array Associative array with options 1033 */ 1034 protected function getAllAvailableSearchTypeOptions() 1035 { 1036 $allOptions = []; 1037 $types = [0, 1, 2, 3, 10, 20]; 1038 $blindSettings = $this->settings['blind']; 1039 if (!$blindSettings['searchType']) { 1040 foreach ($types as $typeNum) { 1041 $allOptions[$typeNum] = LocalizationUtility::translate('searchTypes.' . $typeNum, 'IndexedSearch'); 1042 } 1043 } 1044 // Remove this option if metaphone search is disabled) 1045 if (!$this->enableMetaphoneSearch) { 1046 unset($allOptions[10]); 1047 } 1048 // disable single entries by TypoScript 1049 $allOptions = $this->removeOptionsFromOptionList($allOptions, $blindSettings['searchType']); 1050 return $allOptions; 1051 } 1052 1053 /** 1054 * get the values for the "defaultOperand" selector 1055 * 1056 * @return array Associative array with options 1057 */ 1058 protected function getAllAvailableOperandsOptions() 1059 { 1060 $allOptions = []; 1061 $blindSettings = $this->settings['blind']; 1062 if (!$blindSettings['defaultOperand']) { 1063 $allOptions = [ 1064 0 => LocalizationUtility::translate('defaultOperands.0', 'IndexedSearch'), 1065 1 => LocalizationUtility::translate('defaultOperands.1', 'IndexedSearch') 1066 ]; 1067 } 1068 // disable single entries by TypoScript 1069 $allOptions = $this->removeOptionsFromOptionList($allOptions, $blindSettings['defaultOperand']); 1070 return $allOptions; 1071 } 1072 1073 /** 1074 * get the values for the "media type" selector 1075 * 1076 * @return array Associative array with options 1077 */ 1078 protected function getAllAvailableMediaTypesOptions() 1079 { 1080 $allOptions = []; 1081 $mediaTypes = [-1, 0, -2]; 1082 $blindSettings = $this->settings['blind']; 1083 if (!$blindSettings['mediaType']) { 1084 foreach ($mediaTypes as $mediaType) { 1085 $allOptions[$mediaType] = LocalizationUtility::translate('mediaTypes.' . $mediaType, 'IndexedSearch'); 1086 } 1087 // Add media to search in: 1088 $additionalMedia = trim($this->settings['mediaList']); 1089 if ($additionalMedia !== '') { 1090 $additionalMedia = GeneralUtility::trimExplode(',', $additionalMedia, true); 1091 } else { 1092 $additionalMedia = []; 1093 } 1094 foreach ($this->externalParsers as $extension => $obj) { 1095 // Skip unwanted extensions 1096 if (!empty($additionalMedia) && !in_array($extension, $additionalMedia)) { 1097 continue; 1098 } 1099 if ($name = $obj->searchTypeMediaTitle($extension)) { 1100 $translatedName = LocalizationUtility::translate('mediaTypes.' . $extension, 'IndexedSearch'); 1101 $allOptions[$extension] = $translatedName ?: $name; 1102 } 1103 } 1104 } 1105 // disable single entries by TypoScript 1106 $allOptions = $this->removeOptionsFromOptionList($allOptions, $blindSettings['mediaType']); 1107 return $allOptions; 1108 } 1109 1110 /** 1111 * get the values for the "language" selector 1112 * 1113 * @return array Associative array with options 1114 */ 1115 protected function getAllAvailableLanguageOptions() 1116 { 1117 $allOptions = [ 1118 '-1' => LocalizationUtility::translate('languageUids.-1', 'IndexedSearch') 1119 ]; 1120 $blindSettings = $this->settings['blind']; 1121 if (!$blindSettings['languageUid']) { 1122 try { 1123 $site = GeneralUtility::makeInstance(SiteFinder::class) 1124 ->getSiteByPageId($GLOBALS['TSFE']->id); 1125 1126 $languages = $site->getLanguages(); 1127 foreach ($languages as $language) { 1128 $allOptions[$language->getLanguageId()] = $language->getNavigationTitle() ?? $language->getTitle(); 1129 } 1130 } catch (SiteNotFoundException $e) { 1131 // No Site found, no options 1132 $allOptions = []; 1133 } 1134 1135 // disable single entries by TypoScript 1136 $allOptions = $this->removeOptionsFromOptionList($allOptions, $blindSettings['languageUid']); 1137 } else { 1138 $allOptions = []; 1139 } 1140 return $allOptions; 1141 } 1142 1143 /** 1144 * get the values for the "section" selector 1145 * Here values like "rl1_" and "rl2_" + a rootlevel 1/2 id can be added 1146 * to perform searches in rootlevel 1+2 specifically. The id-values can even 1147 * be commaseparated. Eg. "rl1_1,2" would search for stuff inside pages on 1148 * menu-level 1 which has the uid's 1 and 2. 1149 * 1150 * @return array Associative array with options 1151 */ 1152 protected function getAllAvailableSectionsOptions() 1153 { 1154 $allOptions = []; 1155 $sections = [0, -1, -2, -3]; 1156 $blindSettings = $this->settings['blind']; 1157 if (!$blindSettings['sections']) { 1158 foreach ($sections as $section) { 1159 $allOptions[$section] = LocalizationUtility::translate('sections.' . $section, 'IndexedSearch'); 1160 } 1161 } 1162 // Creating levels for section menu: 1163 // This selects the first and secondary menus for the "sections" selector - so we can search in sections and sub sections. 1164 if ($this->settings['displayLevel1Sections']) { 1165 $firstLevelMenu = $this->getMenuOfPages((int)$this->searchRootPageIdList); 1166 $labelLevel1 = LocalizationUtility::translate('sections.rootLevel1', 'IndexedSearch'); 1167 $labelLevel2 = LocalizationUtility::translate('sections.rootLevel2', 'IndexedSearch'); 1168 foreach ($firstLevelMenu as $firstLevelKey => $menuItem) { 1169 if (!$menuItem['nav_hide']) { 1170 $allOptions['rl1_' . $menuItem['uid']] = trim($labelLevel1 . ' ' . $menuItem['title']); 1171 if ($this->settings['displayLevel2Sections']) { 1172 $secondLevelMenu = $this->getMenuOfPages($menuItem['uid']); 1173 foreach ($secondLevelMenu as $secondLevelKey => $menuItemLevel2) { 1174 if (!$menuItemLevel2['nav_hide']) { 1175 $allOptions['rl2_' . $menuItemLevel2['uid']] = trim($labelLevel2 . ' ' . $menuItemLevel2['title']); 1176 } else { 1177 unset($secondLevelMenu[$secondLevelKey]); 1178 } 1179 } 1180 $allOptions['rl2_' . implode(',', array_keys($secondLevelMenu))] = LocalizationUtility::translate('sections.rootLevel2All', 'IndexedSearch'); 1181 } 1182 } else { 1183 unset($firstLevelMenu[$firstLevelKey]); 1184 } 1185 } 1186 $allOptions['rl1_' . implode(',', array_keys($firstLevelMenu))] = LocalizationUtility::translate('sections.rootLevel1All', 'IndexedSearch'); 1187 } 1188 // disable single entries by TypoScript 1189 $allOptions = $this->removeOptionsFromOptionList($allOptions, $blindSettings['sections']); 1190 return $allOptions; 1191 } 1192 1193 /** 1194 * get the values for the "freeIndexUid" selector 1195 * 1196 * @return array Associative array with options 1197 */ 1198 protected function getAllAvailableIndexConfigurationsOptions() 1199 { 1200 $allOptions = [ 1201 '-1' => LocalizationUtility::translate('indexingConfigurations.-1', 'IndexedSearch'), 1202 '-2' => LocalizationUtility::translate('indexingConfigurations.-2', 'IndexedSearch'), 1203 '0' => LocalizationUtility::translate('indexingConfigurations.0', 'IndexedSearch') 1204 ]; 1205 $blindSettings = $this->settings['blind']; 1206 if (!$blindSettings['indexingConfigurations']) { 1207 // add an additional index configuration 1208 if ($this->settings['defaultFreeIndexUidList']) { 1209 $uidList = GeneralUtility::intExplode(',', $this->settings['defaultFreeIndexUidList']); 1210 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) 1211 ->getQueryBuilderForTable('index_config'); 1212 $queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class)); 1213 $result = $queryBuilder 1214 ->select('uid', 'title') 1215 ->from('index_config') 1216 ->where( 1217 $queryBuilder->expr()->in( 1218 'uid', 1219 $queryBuilder->createNamedParameter($uidList, Connection::PARAM_INT_ARRAY) 1220 ) 1221 ) 1222 ->execute(); 1223 1224 while ($row = $result->fetch()) { 1225 $indexId = (int)$row['uid']; 1226 $title = LocalizationUtility::translate('indexingConfigurations.' . $indexId, 'IndexedSearch'); 1227 $allOptions[$indexId] = $title ?: $row['title']; 1228 } 1229 } 1230 // disable single entries by TypoScript 1231 $allOptions = $this->removeOptionsFromOptionList($allOptions, $blindSettings['indexingConfigurations']); 1232 } else { 1233 $allOptions = []; 1234 } 1235 return $allOptions; 1236 } 1237 1238 /** 1239 * get the values for the "section" selector 1240 * Here values like "rl1_" and "rl2_" + a rootlevel 1/2 id can be added 1241 * to perform searches in rootlevel 1+2 specifically. The id-values can even 1242 * be commaseparated. Eg. "rl1_1,2" would search for stuff inside pages on 1243 * menu-level 1 which has the uid's 1 and 2. 1244 * 1245 * @return array Associative array with options 1246 */ 1247 protected function getAllAvailableSortOrderOptions() 1248 { 1249 $allOptions = []; 1250 $sortOrders = ['rank_flag', 'rank_freq', 'rank_first', 'rank_count', 'mtime', 'title', 'crdate']; 1251 $blindSettings = $this->settings['blind']; 1252 if (!$blindSettings['sortOrder']) { 1253 foreach ($sortOrders as $order) { 1254 $allOptions[$order] = LocalizationUtility::translate('sortOrders.' . $order, 'IndexedSearch'); 1255 } 1256 } 1257 // disable single entries by TypoScript 1258 $allOptions = $this->removeOptionsFromOptionList($allOptions, $blindSettings['sortOrder.']); 1259 return $allOptions; 1260 } 1261 1262 /** 1263 * get the values for the "group" selector 1264 * 1265 * @return array Associative array with options 1266 */ 1267 protected function getAllAvailableGroupOptions() 1268 { 1269 $allOptions = []; 1270 $blindSettings = $this->settings['blind']; 1271 if (!$blindSettings['groupBy']) { 1272 $allOptions = [ 1273 'sections' => LocalizationUtility::translate('groupBy.sections', 'IndexedSearch'), 1274 'flat' => LocalizationUtility::translate('groupBy.flat', 'IndexedSearch') 1275 ]; 1276 } 1277 // disable single entries by TypoScript 1278 $allOptions = $this->removeOptionsFromOptionList($allOptions, $blindSettings['groupBy.']); 1279 return $allOptions; 1280 } 1281 1282 /** 1283 * get the values for the "sortDescending" selector 1284 * 1285 * @return array Associative array with options 1286 */ 1287 protected function getAllAvailableSortDescendingOptions() 1288 { 1289 $allOptions = []; 1290 $blindSettings = $this->settings['blind']; 1291 if (!$blindSettings['descending']) { 1292 $allOptions = [ 1293 0 => LocalizationUtility::translate('sortOrders.descending', 'IndexedSearch'), 1294 1 => LocalizationUtility::translate('sortOrders.ascending', 'IndexedSearch') 1295 ]; 1296 } 1297 // disable single entries by TypoScript 1298 $allOptions = $this->removeOptionsFromOptionList($allOptions, $blindSettings['descending.']); 1299 return $allOptions; 1300 } 1301 1302 /** 1303 * get the values for the "results" selector 1304 * 1305 * @return array Associative array with options 1306 */ 1307 protected function getAllAvailableNumberOfResultsOptions() 1308 { 1309 $allOptions = []; 1310 if (count($this->availableResultsNumbers) > 1) { 1311 $allOptions = array_combine($this->availableResultsNumbers, $this->availableResultsNumbers) ?: []; 1312 } 1313 // disable single entries by TypoScript 1314 $allOptions = $this->removeOptionsFromOptionList($allOptions, $this->settings['blind']['numberOfResults']); 1315 return $allOptions; 1316 } 1317 1318 /** 1319 * removes blinding entries from the option list of a selector 1320 * 1321 * @param array $allOptions associative array containing all options 1322 * @param array $blindOptions associative array containing the optionkey as they key and the value = 1 if it should be removed 1323 * @return array Options from $allOptions with some options removed 1324 */ 1325 protected function removeOptionsFromOptionList($allOptions, $blindOptions) 1326 { 1327 if (is_array($blindOptions)) { 1328 foreach ($blindOptions as $key => $val) { 1329 if ($val == 1) { 1330 unset($allOptions[$key]); 1331 } 1332 } 1333 } 1334 return $allOptions; 1335 } 1336 1337 /** 1338 * Links the $linkText to page $pageUid 1339 * 1340 * @param int $pageUid Page id 1341 * @param array $row Result row 1342 * @param array $markUpSwParams Additional parameters for marking up search words 1343 * @return array 1344 */ 1345 protected function linkPage($pageUid, $row = [], $markUpSwParams = []) 1346 { 1347 $pageLanguage = GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('language', 'contentId', 0); 1348 // Parameters for link 1349 $urlParameters = []; 1350 if ($row['static_page_arguments'] !== null) { 1351 $urlParameters = json_decode($row['static_page_arguments'], true); 1352 } 1353 // Add &type and &MP variable: 1354 if ($row['data_page_mp']) { 1355 $urlParameters['MP'] = $row['data_page_mp']; 1356 } 1357 if (($pageLanguage === 0 && $row['sys_language_uid'] > 0) || $pageLanguage > 0) { 1358 $urlParameters['L'] = (int)$row['sys_language_uid']; 1359 } 1360 // markup-GET vars: 1361 $urlParameters = array_merge($urlParameters, $markUpSwParams); 1362 // This will make sure that the path is retrieved if it hasn't been 1363 // already. Used only for the sake of the domain_record thing. 1364 if (!is_array($this->domainRecords[$pageUid])) { 1365 $this->getPathFromPageId($pageUid); 1366 } 1367 1368 return $this->preparePageLink($pageUid, $row, $urlParameters); 1369 } 1370 1371 /** 1372 * Return the menu of pages used for the selector. 1373 * 1374 * @param int $pageUid Page ID for which to return menu 1375 * @return array Menu items (for making the section selector box) 1376 */ 1377 protected function getMenuOfPages($pageUid) 1378 { 1379 $pageRepository = GeneralUtility::makeInstance(PageRepository::class); 1380 if ($this->settings['displayLevelxAllTypes']) { 1381 return $pageRepository->getMenuForPages([$pageUid]); 1382 } 1383 return $pageRepository->getMenu($pageUid); 1384 } 1385 1386 /** 1387 * Returns the path to the page $id 1388 * 1389 * @param int $id Page ID 1390 * @param string $pathMP Content of the MP (mount point) variable 1391 * @return string Path (HTML-escaped) 1392 */ 1393 protected function getPathFromPageId($id, $pathMP = '') 1394 { 1395 $identStr = $id . '|' . $pathMP; 1396 if (!isset($this->pathCache[$identStr])) { 1397 $this->requiredFrontendUsergroups[$id] = []; 1398 $this->domainRecords[$id] = []; 1399 try { 1400 $rl = GeneralUtility::makeInstance(RootlineUtility::class, $id, $pathMP)->get(); 1401 $path = ''; 1402 $pageCount = count($rl); 1403 if (!empty($rl)) { 1404 $excludeDoktypesFromPath = GeneralUtility::trimExplode( 1405 ',', 1406 $this->settings['results']['pathExcludeDoktypes'] ?? '', 1407 true 1408 ); 1409 $breadcrumbWrap = $this->settings['breadcrumbWrap'] ?? '/'; 1410 $breadcrumbWraps = GeneralUtility::makeInstance(TypoScriptService::class) 1411 ->explodeConfigurationForOptionSplit(['wrap' => $breadcrumbWrap], $pageCount); 1412 foreach ($rl as $k => $v) { 1413 if (in_array($v['doktype'], $excludeDoktypesFromPath, false)) { 1414 continue; 1415 } 1416 // Check fe_user 1417 if ($v['fe_group'] && ($v['uid'] == $id || $v['extendToSubpages'])) { 1418 $this->requiredFrontendUsergroups[$id][] = $v['fe_group']; 1419 } 1420 // Check sys_domain 1421 if ($this->settings['detectDomainRecords']) { 1422 $domainName = $this->getFirstDomainForPage((int)$v['uid']); 1423 if ($domainName) { 1424 $this->domainRecords[$id][] = $domainName; 1425 // Set path accordingly 1426 $path = $domainName . $path; 1427 break; 1428 } 1429 } 1430 // Stop, if we find that the current id is the current root page. 1431 if ($v['uid'] == $GLOBALS['TSFE']->config['rootLine'][0]['uid']) { 1432 array_pop($breadcrumbWraps); 1433 break; 1434 } 1435 $path = $GLOBALS['TSFE']->cObj->wrap(htmlspecialchars($v['title']), array_pop($breadcrumbWraps)['wrap']) . $path; 1436 } 1437 } 1438 } catch (RootLineException $e) { 1439 $path = ''; 1440 } 1441 $this->pathCache[$identStr] = $path; 1442 } 1443 return $this->pathCache[$identStr]; 1444 } 1445 1446 /** 1447 * Gets the first domain for the page 1448 * 1449 * @param int $id Page id 1450 * @return string Domain name 1451 */ 1452 protected function getFirstDomainForPage(int $id): string 1453 { 1454 $domain = ''; 1455 try { 1456 $domain = GeneralUtility::makeInstance(SiteFinder::class) 1457 ->getSiteByRootPageId($id) 1458 ->getBase() 1459 ->getHost(); 1460 } catch (SiteNotFoundException $e) { 1461 // site was not found, we return an empty string as default 1462 } 1463 return $domain; 1464 } 1465 1466 /** 1467 * simple function to initialize possible external parsers 1468 * feeds the $this->externalParsers array 1469 */ 1470 protected function initializeExternalParsers() 1471 { 1472 // Initialize external document parsers for icon display and other soft operations 1473 foreach ($GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['indexed_search']['external_parsers'] ?? [] as $extension => $className) { 1474 $this->externalParsers[$extension] = GeneralUtility::makeInstance($className); 1475 // Init parser and if it returns FALSE, unset its entry again 1476 if (!$this->externalParsers[$extension]->softInit($extension)) { 1477 unset($this->externalParsers[$extension]); 1478 } 1479 } 1480 } 1481 1482 /** 1483 * Returns an object reference to the hook object if any 1484 * 1485 * @param string $functionName Name of the function you want to call / hook key 1486 * @return object|null Hook object, if any. Otherwise NULL. 1487 */ 1488 protected function hookRequest($functionName) 1489 { 1490 // Hook: menuConfig_preProcessModMenu 1491 if ($GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['indexed_search']['pi1_hooks'][$functionName]) { 1492 $hookObj = GeneralUtility::makeInstance($GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['indexed_search']['pi1_hooks'][$functionName]); 1493 if (method_exists($hookObj, $functionName)) { 1494 $hookObj->pObj = $this; 1495 return $hookObj; 1496 } 1497 } 1498 return null; 1499 } 1500 1501 /** 1502 * Returns if an item type is a multipage item type 1503 * 1504 * @param string $item_type Item type 1505 * @return bool TRUE if multipage capable 1506 */ 1507 protected function multiplePagesType($item_type) 1508 { 1509 return is_object($this->externalParsers[$item_type]) && $this->externalParsers[$item_type]->isMultiplePageExtension($item_type); 1510 } 1511 1512 /** 1513 * Process variables related to indexed_search extendedSearch needed by frontend view. 1514 * Populate select boxes and setting some flags. 1515 * The returned data can be passed directly into the view by assignMultiple() 1516 * 1517 * @return array Variables to pass into the view so they can be used in fluid template 1518 */ 1519 protected function processExtendedSearchParameters() 1520 { 1521 $allSearchTypes = $this->getAllAvailableSearchTypeOptions(); 1522 $allDefaultOperands = $this->getAllAvailableOperandsOptions(); 1523 $allMediaTypes = $this->getAllAvailableMediaTypesOptions(); 1524 $allLanguageUids = $this->getAllAvailableLanguageOptions(); 1525 $allSortOrders = $this->getAllAvailableSortOrderOptions(); 1526 $allSortDescendings = $this->getAllAvailableSortDescendingOptions(); 1527 1528 return [ 1529 'allSearchTypes' => $allSearchTypes, 1530 'allDefaultOperands' => $allDefaultOperands, 1531 'showTypeSearch' => !empty($allSearchTypes) || !empty($allDefaultOperands), 1532 'allMediaTypes' => $allMediaTypes, 1533 'allLanguageUids' => $allLanguageUids, 1534 'showMediaAndLanguageSearch' => !empty($allMediaTypes) || !empty($allLanguageUids), 1535 'allSections' => $this->getAllAvailableSectionsOptions(), 1536 'allIndexConfigurations' => $this->getAllAvailableIndexConfigurationsOptions(), 1537 'allSortOrders' => $allSortOrders, 1538 'allSortDescendings' => $allSortDescendings, 1539 'showSortOrders' => !empty($allSortOrders) || !empty($allSortDescendings), 1540 'allNumberOfResults' => $this->getAllAvailableNumberOfResultsOptions(), 1541 'allGroups' => $this->getAllAvailableGroupOptions() 1542 ]; 1543 } 1544 1545 /** 1546 * Load settings and apply stdWrap to them 1547 */ 1548 protected function loadSettings() 1549 { 1550 if (!is_array($this->settings['results.'])) { 1551 $this->settings['results.'] = []; 1552 } 1553 $fullTypoScriptArray = $this->typoScriptService->convertPlainArrayToTypoScriptArray($this->settings); 1554 $this->settings['detectDomainRecords'] = $fullTypoScriptArray['detectDomainRecords'] ?? 0; 1555 $this->settings['detectDomainRecords.'] = $fullTypoScriptArray['detectDomainRecords.'] ?? []; 1556 $typoScriptArray = $fullTypoScriptArray['results.']; 1557 1558 $this->settings['results.']['summaryCropAfter'] = MathUtility::forceIntegerInRange( 1559 $GLOBALS['TSFE']->cObj->stdWrap($typoScriptArray['summaryCropAfter'], $typoScriptArray['summaryCropAfter.']), 1560 10, 1561 5000, 1562 180 1563 ); 1564 $this->settings['results.']['summaryCropSignifier'] = $GLOBALS['TSFE']->cObj->stdWrap($typoScriptArray['summaryCropSignifier'], $typoScriptArray['summaryCropSignifier.']); 1565 $this->settings['results.']['titleCropAfter'] = MathUtility::forceIntegerInRange( 1566 $GLOBALS['TSFE']->cObj->stdWrap($typoScriptArray['titleCropAfter'], $typoScriptArray['titleCropAfter.']), 1567 10, 1568 500, 1569 50 1570 ); 1571 $this->settings['results.']['titleCropSignifier'] = $GLOBALS['TSFE']->cObj->stdWrap($typoScriptArray['titleCropSignifier'], $typoScriptArray['titleCropSignifier.']); 1572 $this->settings['results.']['markupSW_summaryMax'] = MathUtility::forceIntegerInRange( 1573 $GLOBALS['TSFE']->cObj->stdWrap($typoScriptArray['markupSW_summaryMax'], $typoScriptArray['markupSW_summaryMax.']), 1574 10, 1575 5000, 1576 300 1577 ); 1578 $this->settings['results.']['markupSW_postPreLgd'] = MathUtility::forceIntegerInRange( 1579 $GLOBALS['TSFE']->cObj->stdWrap($typoScriptArray['markupSW_postPreLgd'], $typoScriptArray['markupSW_postPreLgd.']), 1580 1, 1581 500, 1582 60 1583 ); 1584 $this->settings['results.']['markupSW_postPreLgd_offset'] = MathUtility::forceIntegerInRange( 1585 $GLOBALS['TSFE']->cObj->stdWrap($typoScriptArray['markupSW_postPreLgd_offset'], $typoScriptArray['markupSW_postPreLgd_offset.']), 1586 1, 1587 50, 1588 5 1589 ); 1590 $this->settings['results.']['markupSW_divider'] = $GLOBALS['TSFE']->cObj->stdWrap($typoScriptArray['markupSW_divider'], $typoScriptArray['markupSW_divider.']); 1591 $this->settings['results.']['hrefInSummaryCropAfter'] = MathUtility::forceIntegerInRange( 1592 $GLOBALS['TSFE']->cObj->stdWrap($typoScriptArray['hrefInSummaryCropAfter'], $typoScriptArray['hrefInSummaryCropAfter.']), 1593 10, 1594 400, 1595 60 1596 ); 1597 $this->settings['results.']['hrefInSummaryCropSignifier'] = $GLOBALS['TSFE']->cObj->stdWrap($typoScriptArray['hrefInSummaryCropSignifier'], $typoScriptArray['hrefInSummaryCropSignifier.']); 1598 } 1599 1600 /** 1601 * Returns number of results to display 1602 * 1603 * @param int $numberOfResults Requested number of results 1604 * @return int 1605 */ 1606 protected function getNumberOfResults($numberOfResults) 1607 { 1608 $numberOfResults = (int)$numberOfResults; 1609 1610 return in_array($numberOfResults, $this->availableResultsNumbers) ? 1611 $numberOfResults : $this->defaultResultNumber; 1612 } 1613 1614 /** 1615 * Internal method to build the page uri and link target. 1616 * @todo make use of the UriBuilder 1617 * 1618 * @param int $pageUid 1619 * @param array $row 1620 * @param array $urlParameters 1621 * @return array 1622 */ 1623 protected function preparePageLink(int $pageUid, array $row, array $urlParameters): array 1624 { 1625 $target = ''; 1626 $uri = $this->controllerContext->getUriBuilder() 1627 ->setTargetPageUid($pageUid) 1628 ->setTargetPageType($row['data_page_type']) 1629 ->setArguments($urlParameters) 1630 ->build(); 1631 1632 // If external domain, then link to that: 1633 if (!empty($this->domainRecords[$pageUid])) { 1634 $scheme = GeneralUtility::getIndpEnv('TYPO3_SSL') ? 'https://' : 'http://'; 1635 $firstDomain = reset($this->domainRecords[$pageUid]); 1636 $uri = $scheme . $firstDomain . $uri; 1637 $target = $this->settings['detectDomainRecords.']['target'] ?? ''; 1638 } 1639 1640 return ['uri' => $uri, 'target' => $target]; 1641 } 1642 1643 /** 1644 * Create a tag for "path" key in search result 1645 * 1646 * @param string $linkText Link text (nodeValue) 1647 * @param array $linkData 1648 * @return string HTML <A> tag wrapped title string. 1649 */ 1650 protected function linkPageATagWrap(string $linkText, array $linkData): string 1651 { 1652 $attributes = [ 1653 'href' => $linkData['uri'] 1654 ]; 1655 if (!empty($linkData['target'])) { 1656 $attributes['target'] = $linkData['target']; 1657 } 1658 return sprintf( 1659 '<a %s>%s</a>', 1660 GeneralUtility::implodeAttributes($attributes, true), 1661 htmlspecialchars($linkText, ENT_QUOTES | ENT_HTML5) 1662 ); 1663 } 1664 1665 /** 1666 * Set the search word 1667 * @param string $sword 1668 */ 1669 public function setSword($sword) 1670 { 1671 $this->sword = (string)$sword; 1672 } 1673 1674 /** 1675 * Returns the search word 1676 * @return string 1677 */ 1678 public function getSword() 1679 { 1680 return (string)$this->sword; 1681 } 1682} 1683