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\Domain\Repository; 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\QueryHelper; 23use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer; 24use TYPO3\CMS\Core\TimeTracker\TimeTracker; 25use TYPO3\CMS\Core\Utility\GeneralUtility; 26use TYPO3\CMS\Core\Utility\MathUtility; 27use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer; 28use TYPO3\CMS\IndexedSearch\Indexer; 29use TYPO3\CMS\IndexedSearch\Utility\IndexedSearchUtility; 30use TYPO3\CMS\IndexedSearch\Utility\LikeWildcard; 31 32/** 33 * Index search abstraction to search through the index 34 * @internal This class is a specific repository implementation and is not considered part of the Public TYPO3 API. 35 */ 36class IndexSearchRepository 37{ 38 /** 39 * External Parsers 40 * 41 * @var array 42 */ 43 protected $externalParsers = []; 44 45 /** 46 * Frontend User Group List 47 * 48 * @var string 49 */ 50 protected $frontendUserGroupList = ''; 51 52 /** 53 * Sections 54 * formally known as $this->piVars['sections'] 55 * 56 * @var string 57 */ 58 protected $sections; 59 60 /** 61 * Search type 62 * formally known as $this->piVars['type'] 63 * 64 * @var int 65 */ 66 protected int $searchType = 0; 67 68 /** 69 * Language uid 70 * formally known as $this->piVars['lang'] 71 * 72 * @var int 73 */ 74 protected $languageUid; 75 76 /** 77 * Media type 78 * formally known as $this->piVars['media'] 79 * 80 * @var int 81 */ 82 protected $mediaType; 83 84 /** 85 * Sort order 86 * formally known as $this->piVars['sort_order'] 87 * 88 * @var string 89 */ 90 protected $sortOrder = ''; 91 92 /** 93 * Descending sort order flag 94 * formally known as $this->piVars['desc'] 95 * 96 * @var bool 97 */ 98 protected $descendingSortOrderFlag; 99 100 /** 101 * Result page pointer 102 * formally known as $this->piVars['pointer'] 103 * 104 * @var int 105 */ 106 protected $resultpagePointer = 0; 107 108 /** 109 * Number of results 110 * formally known as $this->piVars['result'] 111 * 112 * @var int 113 */ 114 protected $numberOfResults = 10; 115 116 /** 117 * list of all root pages that will be used 118 * If this value is set to less than zero (eg. -1) searching will happen 119 * in ALL of the page tree with no regard to branches at all. 120 * 121 * @var string 122 */ 123 protected $searchRootPageIdList = ''; 124 125 /** 126 * formally known as $conf['search.']['searchSkipExtendToSubpagesChecking'] 127 * enabled through settings.searchSkipExtendToSubpagesChecking 128 * 129 * @var bool 130 */ 131 protected $joinPagesForQuery = false; 132 133 /** 134 * Select clauses for individual words, will be filled during the search 135 * 136 * @var array 137 */ 138 protected $wSelClauses = []; 139 140 /** 141 * Flag for exact search count 142 * formally known as $conf['search.']['exactCount'] 143 * 144 * Continue counting and checking of results even if we are sure 145 * they are not displayed in this request. This will slow down your 146 * page rendering, but it allows precise search result counters. 147 * enabled through settings.exactCount 148 * 149 * @var bool 150 */ 151 protected $useExactCount = false; 152 153 /** 154 * Display forbidden records 155 * formally known as $this->conf['show.']['forbiddenRecords'] 156 * 157 * enabled through settings.displayForbiddenRecords 158 * 159 * @var bool 160 */ 161 protected $displayForbiddenRecords = false; 162 163 /** 164 * initialize all options that are necessary for the search 165 * 166 * @param array $settings the extbase plugin settings 167 * @param array $searchData the search data 168 * @param array $externalParsers 169 * @param string $searchRootPageIdList 170 */ 171 public function initialize($settings, $searchData, $externalParsers, $searchRootPageIdList) 172 { 173 $this->externalParsers = $externalParsers; 174 $this->searchRootPageIdList = (string)$searchRootPageIdList; 175 $this->frontendUserGroupList = implode(',', GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('frontend.user', 'groupIds', [0, -1])); 176 // Should we use joinPagesForQuery instead of long lists of uids? 177 if ($settings['searchSkipExtendToSubpagesChecking'] ?? false) { 178 $this->joinPagesForQuery = true; 179 } 180 if ($settings['exactCount'] ?? false) { 181 $this->useExactCount = true; 182 } 183 if ($settings['displayForbiddenRecords'] ?? false) { 184 $this->displayForbiddenRecords = true; 185 } 186 $this->sections = (string)($searchData['sections'] ?? ''); 187 $this->searchType = (int)($searchData['searchType'] ?? 0); 188 $this->languageUid = (int)($searchData['languageUid'] ?? 0); 189 $this->mediaType = $searchData['mediaType'] ?? 0; 190 $this->sortOrder = (string)($searchData['sortOrder'] ?? ''); 191 $this->descendingSortOrderFlag = $searchData['desc'] ?? false; 192 $this->resultpagePointer = (int)($searchData['pointer'] ?? 0); 193 if (is_numeric($searchData['numberOfResults'] ?? null)) { 194 $this->numberOfResults = (int)$searchData['numberOfResults']; 195 } 196 } 197 198 /** 199 * Get search result rows / data from database. Returned as data in array. 200 * 201 * @param array $searchWords Search word array 202 * @param int $freeIndexUid Pointer to which indexing configuration you want to search in. -1 means no filtering. 0 means only regular indexed content. 203 * @return bool|array FALSE if no result, otherwise an array with keys for first row, result rows and total number of results found. 204 */ 205 public function doSearch($searchWords, $freeIndexUid = -1) 206 { 207 $useMysqlFulltext = (bool)GeneralUtility::makeInstance(ExtensionConfiguration::class)->get('indexed_search', 'useMysqlFulltext'); 208 // Getting SQL result pointer: 209 $this->getTimeTracker()->push('Searching result'); 210 if ($hookObj = $this->hookRequest('getResultRows_SQLpointer')) { 211 $result = $hookObj->getResultRows_SQLpointer($searchWords, $freeIndexUid); 212 } elseif ($useMysqlFulltext) { 213 $result = $this->getResultRows_SQLpointerMysqlFulltext($searchWords, $freeIndexUid); 214 } else { 215 $result = $this->getResultRows_SQLpointer($searchWords, $freeIndexUid); 216 } 217 $this->getTimeTracker()->pull(); 218 // Organize and process result: 219 if ($result) { 220 // Total search-result count 221 $count = $result->rowCount(); 222 // The pointer is set to the result page that is currently being viewed 223 $pointer = MathUtility::forceIntegerInRange($this->resultpagePointer, 0, (int)floor($count / $this->numberOfResults)); 224 // Initialize result accumulation variables: 225 $c = 0; 226 // Result pointer: Counts up the position in the current search-result 227 $grouping_phashes = []; 228 // Used to filter out duplicates. 229 $grouping_chashes = []; 230 // Used to filter out duplicates BASED ON cHash. 231 $firstRow = []; 232 // Will hold the first row in result - used to calculate relative hit-ratings. 233 $resultRows = []; 234 // Will hold the results rows for display. 235 // Now, traverse result and put the rows to be displayed into an array 236 // Each row should contain the fields from 'ISEC.*, IP.*' combined 237 // + artificial fields "show_resume" (bool) and "result_number" (counter) 238 while ($row = $result->fetchAssociative()) { 239 // Set first row 240 if (!$c) { 241 $firstRow = $row; 242 } 243 // Tells whether we can link directly to a document 244 // or not (depends on possible right problems) 245 $row['show_resume'] = $this->checkResume($row); 246 $phashGr = !in_array($row['phash_grouping'], $grouping_phashes); 247 $chashGr = !in_array($row['contentHash'] . '.' . $row['data_page_id'], $grouping_chashes); 248 if ($phashGr && $chashGr) { 249 // Only if the resume may be shown are we going to filter out duplicates... 250 if ($row['show_resume'] || $this->displayForbiddenRecords) { 251 // Only on documents which are not multiple pages documents 252 if (!$this->multiplePagesType($row['item_type'] ?? '')) { 253 $grouping_phashes[] = $row['phash_grouping']; 254 } 255 $grouping_chashes[] = $row['contentHash'] . '.' . $row['data_page_id']; 256 // Increase the result pointer 257 $c++; 258 // All rows for display is put into resultRows[] 259 if ($c > $pointer * $this->numberOfResults && $c <= $pointer * $this->numberOfResults + $this->numberOfResults) { 260 $row['result_number'] = $c; 261 $resultRows[] = $row; 262 // This may lead to a problem: If the result check is not stopped here, the search will take longer. 263 // However the result counter will not filter out grouped cHashes/pHashes that were not processed yet. 264 // You can change this behavior using the "settings.exactCount" property (see above). 265 if (!$this->useExactCount && $c + 1 > ($pointer + 1) * $this->numberOfResults) { 266 break; 267 } 268 } 269 } else { 270 // Skip this row if the user cannot 271 // view it (missing permission) 272 $count--; 273 } 274 } else { 275 // For each time a phash_grouping document is found 276 // (which is thus not displayed) the search-result count is reduced, 277 // so that it matches the number of rows displayed. 278 $count--; 279 } 280 } 281 282 $result->free(); 283 284 return [ 285 'resultRows' => $resultRows, 286 'firstRow' => $firstRow, 287 'count' => $count, 288 ]; 289 } 290 // No results found 291 return false; 292 } 293 294 /** 295 * Gets a SQL result pointer to traverse for the search records. 296 * 297 * @param array $searchWords Search words 298 * @param int $freeIndexUid Pointer to which indexing configuration you want to search in. -1 means no filtering. 0 means only regular indexed content. 299 * @return \Doctrine\DBAL\Result|int|bool 300 */ 301 protected function getResultRows_SQLpointer($searchWords, $freeIndexUid = -1) 302 { 303 // This SEARCHES for the searchwords in $searchWords AND returns a 304 // COMPLETE list of phash-integers of the matches. 305 $list = $this->getPhashList($searchWords); 306 // Perform SQL Search / collection of result rows array: 307 if ($list) { 308 // Do the search: 309 $this->getTimeTracker()->push('execFinalQuery'); 310 $res = $this->execFinalQuery($list, $freeIndexUid); 311 $this->getTimeTracker()->pull(); 312 return $res; 313 } 314 return false; 315 } 316 317 /** 318 * Gets a SQL result pointer to traverse for the search records. 319 * 320 * mysql fulltext specific version triggered by ext_conf_template setting 'useMysqlFulltext' 321 * 322 * @param array $searchWordsArray Search words 323 * @param int $freeIndexUid Pointer to which indexing configuration you want to search in. -1 means no filtering. 0 means only regular indexed content. 324 * @return \Doctrine\DBAL\Result|int|bool DBAL result statement 325 */ 326 protected function getResultRows_SQLpointerMysqlFulltext($searchWordsArray, $freeIndexUid = -1) 327 { 328 $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('index_fulltext'); 329 if (strpos($connection->getServerVersion(), 'MySQL') !== 0) { 330 throw new \RuntimeException( 331 'Extension indexed_search is configured to use mysql fulltext, but table \'index_fulltext\'' 332 . ' is running on a different DBMS.', 333 1472585525 334 ); 335 } 336 // Build the search string, detect which fulltext index to use, and decide whether boolean search is needed or not 337 $searchData = $this->getSearchString($searchWordsArray); 338 // Perform SQL Search / collection of result rows array: 339 $resource = false; 340 if ($searchData) { 341 /** @var TimeTracker $timeTracker */ 342 $timeTracker = GeneralUtility::makeInstance(TimeTracker::class); 343 // Do the search: 344 $timeTracker->push('execFinalQuery'); 345 $resource = $this->execFinalQuery_fulltext($searchData, $freeIndexUid); 346 $timeTracker->pull(); 347 } 348 return $resource; 349 } 350 351 /** 352 * Returns a search string for use with MySQL FULLTEXT query 353 * 354 * mysql fulltext specific helper method 355 * 356 * @param array $searchWordArray Search word array 357 * @return array Search string 358 */ 359 protected function getSearchString($searchWordArray) 360 { 361 // Change this to TRUE to force BOOLEAN SEARCH MODE (useful if fulltext index is still empty) 362 $searchBoolean = false; 363 $fulltextIndex = 'index_fulltext.fulltextdata'; 364 // This holds the result if the search is natural (doesn't contain any boolean operators) 365 $naturalSearchString = ''; 366 // This holds the result if the search is boolean (contains +/-/| operators) 367 $booleanSearchString = ''; 368 369 $searchType = $this->getSearchType(); 370 371 // Traverse searchwords and prefix them with corresponding operator 372 foreach ($searchWordArray as $searchWordData) { 373 // Making the query for a single search word based on the search-type 374 $searchWord = $searchWordData['sword']; 375 $wildcard = ''; 376 if (str_contains($searchWord, ' ')) { 377 $searchType = 20; 378 } 379 switch ($searchType) { 380 case 1: 381 case 2: 382 case 3: 383 // First part of word 384 $wildcard = '*'; 385 // Part-of-word search requires boolean mode! 386 $searchBoolean = true; 387 break; 388 case 10: 389 $indexerObj = GeneralUtility::makeInstance(Indexer::class); 390 $searchWord = $indexerObj->metaphone($searchWord, $indexerObj->storeMetaphoneInfoAsWords); 391 $fulltextIndex = 'index_fulltext.metaphonedata'; 392 break; 393 case 20: 394 $searchBoolean = true; 395 // Remove existing quotes and fix misplaced quotes. 396 $searchWord = trim(str_replace('"', ' ', $searchWord)); 397 break; 398 } 399 // Perform search for word: 400 switch ($searchWordData['oper']) { 401 case 'AND NOT': 402 $booleanSearchString .= ' -' . $searchWord . $wildcard; 403 $searchBoolean = true; 404 break; 405 case 'OR': 406 $booleanSearchString .= ' ' . $searchWord . $wildcard; 407 $searchBoolean = true; 408 break; 409 default: 410 $booleanSearchString .= ' +' . $searchWord . $wildcard; 411 $naturalSearchString .= ' ' . $searchWord; 412 } 413 } 414 if ($searchType === 20) { 415 $searchString = '"' . trim($naturalSearchString) . '"'; 416 } elseif ($searchBoolean) { 417 $searchString = trim($booleanSearchString); 418 } else { 419 $searchString = trim($naturalSearchString); 420 } 421 return [ 422 'searchBoolean' => $searchBoolean, 423 'searchString' => $searchString, 424 'fulltextIndex' => $fulltextIndex, 425 ]; 426 } 427 428 /** 429 * Execute final query, based on phash integer list. The main point is sorting the result in the right order. 430 * 431 * mysql fulltext specific helper method 432 * 433 * @param array $searchData Array with search string, boolean indicator, and fulltext index reference 434 * @param int $freeIndexUid Pointer to which indexing configuration you want to search in. -1 means no filtering. 0 means only regular indexed content. 435 * @return \Doctrine\DBAL\Result 436 */ 437 protected function execFinalQuery_fulltext($searchData, $freeIndexUid = -1) 438 { 439 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('index_fulltext'); 440 $queryBuilder->getRestrictions()->removeAll(); 441 $queryBuilder->select('index_fulltext.*', 'ISEC.*', 'IP.*') 442 ->from('index_fulltext') 443 ->join( 444 'index_fulltext', 445 'index_phash', 446 'IP', 447 $queryBuilder->expr()->eq('index_fulltext.phash', $queryBuilder->quoteIdentifier('IP.phash')) 448 ) 449 ->join( 450 'IP', 451 'index_section', 452 'ISEC', 453 $queryBuilder->expr()->eq('IP.phash', $queryBuilder->quoteIdentifier('ISEC.phash')) 454 ); 455 456 // Calling hook for alternative creation of page ID list 457 $searchRootPageIdList = $this->getSearchRootPageIdList(); 458 if ($hookObj = $this->hookRequest('execFinalQuery_idList')) { 459 $pageWhere = $hookObj->execFinalQuery_idList(''); 460 $queryBuilder->andWhere(QueryHelper::stripLogicalOperatorPrefix($pageWhere)); 461 } elseif ($this->joinPagesForQuery) { 462 // Alternative to getting all page ids by ->getTreeList() where "excludeSubpages" is NOT respected. 463 $queryBuilder 464 ->join( 465 'ISEC', 466 'pages', 467 'pages', 468 $queryBuilder->expr()->eq('ISEC.page_id', $queryBuilder->quoteIdentifier('pages.uid')) 469 ) 470 ->andWhere( 471 $queryBuilder->expr()->eq( 472 'pages.no_search', 473 $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT) 474 ) 475 ) 476 ->andWhere( 477 $queryBuilder->expr()->lt( 478 'pages.doktype', 479 $queryBuilder->createNamedParameter(200, \PDO::PARAM_INT) 480 ) 481 ); 482 $queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class)); 483 } elseif ($searchRootPageIdList[0] >= 0) { 484 // Collecting all pages IDs in which to search; 485 // filtering out ALL pages that are not accessible due to restriction containers. Does NOT look for "no_search" field! 486 $idList = []; 487 foreach ($searchRootPageIdList as $rootId) { 488 /** @var \TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer $cObj */ 489 $cObj = GeneralUtility::makeInstance(ContentObjectRenderer::class); 490 $idList[] = $cObj->getTreeList(-1 * $rootId, 9999); 491 } 492 $idList = GeneralUtility::intExplode(',', implode(',', $idList)); 493 $queryBuilder->andWhere( 494 $queryBuilder->expr()->in( 495 'ISEC.page_id', 496 $queryBuilder->createNamedParameter($idList, Connection::PARAM_INT_ARRAY) 497 ) 498 ); 499 } 500 501 $searchBoolean = ''; 502 if ($searchData['searchBoolean']) { 503 $searchBoolean = ' IN BOOLEAN MODE'; 504 } 505 $queryBuilder->andWhere( 506 'MATCH (' . $queryBuilder->quoteIdentifier($searchData['fulltextIndex']) . ')' 507 . ' AGAINST (' . $queryBuilder->createNamedParameter($searchData['searchString']) 508 . $searchBoolean 509 . ')' 510 ); 511 512 $queryBuilder->andWhere( 513 QueryHelper::stripLogicalOperatorPrefix($this->mediaTypeWhere()), 514 QueryHelper::stripLogicalOperatorPrefix($this->languageWhere()), 515 QueryHelper::stripLogicalOperatorPrefix($this->freeIndexUidWhere($freeIndexUid)), 516 QueryHelper::stripLogicalOperatorPrefix($this->sectionTableWhere()) 517 ); 518 519 $queryBuilder->groupBy( 520 'IP.phash', 521 'ISEC.phash', 522 'ISEC.phash_t3', 523 'ISEC.rl0', 524 'ISEC.rl1', 525 'ISEC.rl2', 526 'ISEC.page_id', 527 'ISEC.uniqid', 528 'IP.phash_grouping', 529 'IP.data_filename', 530 'IP.data_page_id', 531 'IP.data_page_type', 532 'IP.data_page_mp', 533 'IP.gr_list', 534 'IP.item_type', 535 'IP.item_title', 536 'IP.item_description', 537 'IP.item_mtime', 538 'IP.tstamp', 539 'IP.item_size', 540 'IP.contentHash', 541 'IP.crdate', 542 'IP.parsetime', 543 'IP.sys_language_uid', 544 'IP.item_crdate', 545 'IP.externalUrl', 546 'IP.recordUid', 547 'IP.freeIndexUid', 548 'IP.freeIndexSetId' 549 ); 550 551 return $queryBuilder->executeQuery(); 552 } 553 554 /*********************************** 555 * 556 * Helper functions on searching (SQL) 557 * 558 ***********************************/ 559 /** 560 * Returns a COMPLETE list of phash-integers matching the search-result composed of the search-words in the $searchWords array. 561 * The list of phash integers are unsorted and should be used for subsequent selection of index_phash records for display of the result. 562 * 563 * @param array $searchWords Search word array 564 * @return string List of integers 565 */ 566 protected function getPhashList($searchWords) 567 { 568 // Initialize variables: 569 $c = 0; 570 // This array accumulates the phash-values 571 $totalHashList = []; 572 $this->wSelClauses = []; 573 // Traverse searchwords; for each, select all phash integers and merge/diff/intersect them with previous word (based on operator) 574 foreach ($searchWords as $v) { 575 // Making the query for a single search word based on the search-type 576 $sWord = $v['sword'] ?? ''; 577 $theType = $this->searchType; 578 // If there are spaces in the search-word, make a full text search instead. 579 if (str_contains($sWord, ' ')) { 580 $theType = 20; 581 } 582 $this->getTimeTracker()->push('SearchWord "' . $sWord . '" - $theType=' . (string)$theType); 583 // Perform search for word: 584 switch ($theType) { 585 case 1: 586 // Part of word 587 $res = $this->searchWord($sWord, LikeWildcard::BOTH); 588 break; 589 case 2: 590 // First part of word 591 $res = $this->searchWord($sWord, LikeWildcard::RIGHT); 592 break; 593 case 3: 594 // Last part of word 595 $res = $this->searchWord($sWord, LikeWildcard::LEFT); 596 break; 597 case 10: 598 // Sounds like 599 $indexerObj = GeneralUtility::makeInstance(Indexer::class); 600 // Perform metaphone search 601 $storeMetaphoneInfoAsWords = !$this->isTableUsed('index_words'); 602 $res = $this->searchMetaphone($indexerObj->metaphone($sWord, $storeMetaphoneInfoAsWords)); 603 break; 604 case 20: 605 // Sentence 606 $res = $this->searchSentence($sWord); 607 // If there is a fulltext search for a sentence there is 608 // a likeliness that sorting cannot be done by the rankings 609 // from the rel-table (because no relations will exist for the 610 // sentence in the word-table). So therefore mtime is used instead. 611 // It is not required, but otherwise some hits may be left out. 612 $this->sortOrder = 'mtime'; 613 break; 614 default: 615 // Distinct word 616 $res = $this->searchDistinct($sWord); 617 } 618 // If there was a query to do, then select all phash-integers which resulted from this. 619 if ($res) { 620 // Get phash list by searching for it: 621 $phashList = []; 622 while ($row = $res->fetchAssociative()) { 623 $phashList[] = $row['phash']; 624 } 625 // Here the phash list are merged with the existing result based on whether we are dealing with OR, NOT or AND operations. 626 if ($c) { 627 switch ($v['oper']) { 628 case 'OR': 629 $totalHashList = array_unique(array_merge($phashList, $totalHashList)); 630 break; 631 case 'AND NOT': 632 $totalHashList = array_diff($totalHashList, $phashList); 633 break; 634 default: 635 // AND... 636 $totalHashList = array_intersect($totalHashList, $phashList); 637 } 638 } else { 639 // First search 640 $totalHashList = $phashList; 641 } 642 } 643 $this->getTimeTracker()->pull(); 644 $c++; 645 } 646 return implode(',', $totalHashList); 647 } 648 649 /** 650 * Returns a query which selects the search-word from the word/rel tables. 651 * 652 * @param string $wordSel WHERE clause selecting the word from phash 653 * @param string $additionalWhereClause Additional AND clause in the end of the query. 654 * @return \Doctrine\DBAL\Result|int 655 */ 656 protected function execPHashListQuery($wordSel, $additionalWhereClause = '') 657 { 658 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('index_words'); 659 $queryBuilder->select('IR.phash') 660 ->from('index_words', 'IW') 661 ->from('index_rel', 'IR') 662 ->from('index_section', 'ISEC') 663 ->where( 664 QueryHelper::stripLogicalOperatorPrefix($wordSel), 665 $queryBuilder->expr()->eq('IW.wid', $queryBuilder->quoteIdentifier('IR.wid')), 666 $queryBuilder->expr()->eq('ISEC.phash', $queryBuilder->quoteIdentifier('IR.phash')), 667 QueryHelper::stripLogicalOperatorPrefix($this->sectionTableWhere()), 668 QueryHelper::stripLogicalOperatorPrefix($additionalWhereClause) 669 ) 670 ->groupBy('IR.phash'); 671 672 return $queryBuilder->executeQuery(); 673 } 674 675 /** 676 * Search for a word 677 * 678 * @param string $sWord the search word 679 * @param int $wildcard Bit-field of Utility\LikeWildcard 680 * @return \Doctrine\DBAL\Result 681 */ 682 protected function searchWord($sWord, $wildcard) 683 { 684 $likeWildcard = LikeWildcard::cast($wildcard); 685 $wSel = $likeWildcard->getLikeQueryPart( 686 'index_words', 687 'IW.baseword', 688 $sWord 689 ); 690 $this->wSelClauses[] = $wSel; 691 return $this->execPHashListQuery($wSel, ' AND is_stopword=0'); 692 } 693 694 /** 695 * Search for one distinct word 696 * 697 * @param string $sWord the search word 698 * @return \Doctrine\DBAL\Result 699 */ 700 protected function searchDistinct($sWord) 701 { 702 $expressionBuilder = GeneralUtility::makeInstance(ConnectionPool::class) 703 ->getQueryBuilderForTable('index_words') 704 ->expr(); 705 $wSel = $expressionBuilder->eq('IW.wid', $this->md5inthash($sWord)); 706 $this->wSelClauses[] = $wSel; 707 return $this->execPHashListQuery($wSel, $expressionBuilder->eq('is_stopword', 0)); 708 } 709 710 /** 711 * Search for a sentence 712 * 713 * @param string $sWord the search word 714 * @return \Doctrine\DBAL\Result 715 */ 716 protected function searchSentence($sWord) 717 { 718 $this->wSelClauses[] = '1=1'; 719 $likeWildcard = LikeWildcard::cast(LikeWildcard::BOTH); 720 $likePart = $likeWildcard->getLikeQueryPart( 721 'index_fulltext', 722 'IFT.fulltextdata', 723 $sWord 724 ); 725 726 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('index_section'); 727 return $queryBuilder->select('ISEC.phash') 728 ->from('index_section', 'ISEC') 729 ->from('index_fulltext', 'IFT') 730 ->where( 731 QueryHelper::stripLogicalOperatorPrefix($likePart), 732 $queryBuilder->expr()->eq('ISEC.phash', $queryBuilder->quoteIdentifier('IFT.phash')), 733 QueryHelper::stripLogicalOperatorPrefix($this->sectionTableWhere()) 734 ) 735 ->groupBy('ISEC.phash') 736 ->executeQuery(); 737 } 738 739 /** 740 * Search for a metaphone word 741 * 742 * @param string $sWord the search word 743 * @return \Doctrine\DBAL\Result 744 */ 745 protected function searchMetaphone($sWord) 746 { 747 $expressionBuilder = GeneralUtility::makeInstance(ConnectionPool::class) 748 ->getQueryBuilderForTable('index_words') 749 ->expr(); 750 $wSel = $expressionBuilder->eq('IW.metaphone', $expressionBuilder->literal($sWord)); 751 $this->wSelClauses[] = $wSel; 752 return $this->execPHashListQuery($wSel, $expressionBuilder->eq('is_stopword', 0)); 753 } 754 755 /** 756 * Returns AND statement for selection of section in database. (rootlevel 0-2 + page_id) 757 * 758 * @return string AND clause for selection of section in database. 759 */ 760 public function sectionTableWhere() 761 { 762 $expressionBuilder = GeneralUtility::makeInstance(ConnectionPool::class) 763 ->getQueryBuilderForTable('index_section') 764 ->expr(); 765 766 $whereClause = $expressionBuilder->andX(); 767 $match = false; 768 if (!($this->searchRootPageIdList < 0)) { 769 $whereClause->add( 770 $expressionBuilder->in('ISEC.rl0', GeneralUtility::intExplode(',', $this->searchRootPageIdList, true)) 771 ); 772 } 773 if (strpos($this->sections, 'rl1_') === 0) { 774 $whereClause->add( 775 $expressionBuilder->in('ISEC.rl1', GeneralUtility::intExplode(',', substr($this->sections, 4))) 776 ); 777 $match = true; 778 } elseif (strpos($this->sections, 'rl2_') === 0) { 779 $whereClause->add( 780 $expressionBuilder->in('ISEC.rl2', GeneralUtility::intExplode(',', substr($this->sections, 4))) 781 ); 782 $match = true; 783 } else { 784 // Traversing user configured fields to see if any of those are used to limit search to a section: 785 foreach ($GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['indexed_search']['addRootLineFields'] ?? [] as $fieldName => $rootLineLevel) { 786 if (strpos($this->sections, $fieldName . '_') === 0) { 787 $whereClause->add( 788 $expressionBuilder->in( 789 'ISEC.' . $fieldName, 790 GeneralUtility::intExplode(',', substr($this->sections, strlen($fieldName) + 1)) 791 ) 792 ); 793 $match = true; 794 break; 795 } 796 } 797 } 798 // If no match above, test the static types: 799 if (!$match) { 800 switch ((string)$this->sections) { 801 case '-1': 802 $whereClause->add( 803 $expressionBuilder->eq('ISEC.page_id', (int)$this->getTypoScriptFrontendController()->id) 804 ); 805 break; 806 case '-2': 807 $whereClause->add($expressionBuilder->eq('ISEC.rl2', 0)); 808 break; 809 case '-3': 810 $whereClause->add($expressionBuilder->gt('ISEC.rl2', 0)); 811 break; 812 } 813 } 814 815 return $whereClause->count() ? ' AND ' . $whereClause : ''; 816 } 817 818 /** 819 * Returns AND statement for selection of media type 820 * 821 * @return string AND statement for selection of media type 822 */ 823 public function mediaTypeWhere() 824 { 825 $expressionBuilder = GeneralUtility::makeInstance(ConnectionPool::class) 826 ->getQueryBuilderForTable('index_phash') 827 ->expr(); 828 switch ($this->mediaType) { 829 case '0': 830 // '0' => 'only TYPO3 pages', 831 $whereClause = $expressionBuilder->eq('IP.item_type', $expressionBuilder->literal('0')); 832 break; 833 case '-2': 834 // All external documents 835 $whereClause = $expressionBuilder->neq('IP.item_type', $expressionBuilder->literal('0')); 836 break; 837 case false: 838 // Intentional fall-through 839 case '-1': 840 // All content 841 $whereClause = ''; 842 break; 843 default: 844 $whereClause = $expressionBuilder->eq('IP.item_type', $expressionBuilder->literal($this->mediaType)); 845 } 846 return $whereClause ? ' AND ' . $whereClause : ''; 847 } 848 849 /** 850 * Returns AND statement for selection of language 851 * 852 * @return string AND statement for selection of language 853 */ 854 public function languageWhere() 855 { 856 // -1 is the same as ALL language. 857 if ($this->languageUid < 0) { 858 return ''; 859 } 860 861 $expressionBuilder = GeneralUtility::makeInstance(ConnectionPool::class) 862 ->getQueryBuilderForTable('index_phash') 863 ->expr(); 864 865 return ' AND ' . $expressionBuilder->eq('IP.sys_language_uid', $this->languageUid); 866 } 867 868 /** 869 * Where-clause for free index-uid value. 870 * 871 * @param int $freeIndexUid Free Index UID value to limit search to. 872 * @return string WHERE SQL clause part. 873 */ 874 public function freeIndexUidWhere($freeIndexUid) 875 { 876 $freeIndexUid = (int)$freeIndexUid; 877 if ($freeIndexUid < 0) { 878 return ''; 879 } 880 // First, look if the freeIndexUid is a meta configuration: 881 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) 882 ->getQueryBuilderForTable('index_config'); 883 $indexCfgRec = $queryBuilder->select('indexcfgs') 884 ->from('index_config') 885 ->where( 886 $queryBuilder->expr()->eq('type', $queryBuilder->createNamedParameter(5, \PDO::PARAM_INT)), 887 $queryBuilder->expr()->eq( 888 'uid', 889 $queryBuilder->createNamedParameter($freeIndexUid, \PDO::PARAM_INT) 890 ) 891 ) 892 ->executeQuery() 893 ->fetchAssociative(); 894 895 if (is_array($indexCfgRec)) { 896 $refs = GeneralUtility::trimExplode(',', $indexCfgRec['indexcfgs']); 897 // Default value to protect against empty array. 898 $list = [-99]; 899 foreach ($refs as $ref) { 900 [$table, $uid] = GeneralUtility::revExplode('_', $ref, 2); 901 $uid = (int)$uid; 902 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) 903 ->getQueryBuilderForTable('index_config'); 904 $queryBuilder->select('uid') 905 ->from('index_config'); 906 switch ($table) { 907 case 'index_config': 908 $idxRec = $queryBuilder 909 ->where( 910 $queryBuilder->expr()->eq( 911 'uid', 912 $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT) 913 ) 914 ) 915 ->executeQuery() 916 ->fetchAssociative(); 917 if ($idxRec) { 918 $list[] = $uid; 919 } 920 break; 921 case 'pages': 922 $indexCfgRecordsFromPid = $queryBuilder 923 ->where( 924 $queryBuilder->expr()->eq( 925 'pid', 926 $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT) 927 ) 928 ) 929 ->executeQuery(); 930 while ($idxRec = $indexCfgRecordsFromPid->fetchAssociative()) { 931 $list[] = $idxRec['uid']; 932 } 933 break; 934 } 935 } 936 $list = array_unique($list); 937 } else { 938 $list = [$freeIndexUid]; 939 } 940 941 $expressionBuilder = GeneralUtility::makeInstance(ConnectionPool::class) 942 ->getQueryBuilderForTable('index_phash') 943 ->expr(); 944 return ' AND ' . $expressionBuilder->in('IP.freeIndexUid', array_map('intval', $list)); 945 } 946 947 /** 948 * Execute final query, based on phash integer list. The main point is sorting the result in the right order. 949 * 950 * @param string $list List of phash integers which match the search. 951 * @param int $freeIndexUid Pointer to which indexing configuration you want to search in. -1 means no filtering. 0 means only regular indexed content. 952 * @return \Doctrine\DBAL\Result 953 */ 954 protected function execFinalQuery($list, $freeIndexUid = -1) 955 { 956 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('index_words'); 957 $queryBuilder->select('ISEC.*', 'IP.*') 958 ->from('index_phash', 'IP') 959 ->from('index_section', 'ISEC') 960 ->where( 961 $queryBuilder->expr()->in( 962 'IP.phash', 963 $queryBuilder->createNamedParameter( 964 GeneralUtility::intExplode(',', $list, true), 965 Connection::PARAM_INT_ARRAY 966 ) 967 ), 968 QueryHelper::stripLogicalOperatorPrefix($this->mediaTypeWhere()), 969 QueryHelper::stripLogicalOperatorPrefix($this->languageWhere()), 970 QueryHelper::stripLogicalOperatorPrefix($this->freeIndexUidWhere($freeIndexUid)), 971 $queryBuilder->expr()->eq('ISEC.phash', $queryBuilder->quoteIdentifier('IP.phash')) 972 ) 973 ->groupBy( 974 'IP.phash', 975 'ISEC.phash', 976 'ISEC.phash_t3', 977 'ISEC.rl0', 978 'ISEC.rl1', 979 'ISEC.rl2', 980 'ISEC.page_id', 981 'ISEC.uniqid', 982 'IP.phash_grouping', 983 'IP.data_filename', 984 'IP.data_page_id', 985 'IP.data_page_type', 986 'IP.data_page_mp', 987 'IP.gr_list', 988 'IP.item_type', 989 'IP.item_title', 990 'IP.item_description', 991 'IP.item_mtime', 992 'IP.tstamp', 993 'IP.item_size', 994 'IP.contentHash', 995 'IP.crdate', 996 'IP.parsetime', 997 'IP.sys_language_uid', 998 'IP.item_crdate', 999 'IP.externalUrl', 1000 'IP.recordUid', 1001 'IP.freeIndexUid', 1002 'IP.freeIndexSetId', 1003 'IP.static_page_arguments' 1004 ); 1005 1006 // Setting up methods of filtering results 1007 // based on page types, access, etc. 1008 if ($hookObj = $this->hookRequest('execFinalQuery_idList')) { 1009 // Calling hook for alternative creation of page ID list 1010 $hookWhere = QueryHelper::stripLogicalOperatorPrefix($hookObj->execFinalQuery_idList($list)); 1011 if (!empty($hookWhere)) { 1012 $queryBuilder->andWhere($hookWhere); 1013 } 1014 } elseif ($this->joinPagesForQuery) { 1015 // Alternative to getting all page ids by ->getTreeList() where 1016 // "excludeSubpages" is NOT respected. 1017 $queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class)); 1018 $queryBuilder->from('pages'); 1019 $queryBuilder->andWhere( 1020 $queryBuilder->expr()->eq('pages.uid', $queryBuilder->quoteIdentifier('ISEC.page_id')), 1021 $queryBuilder->expr()->eq( 1022 'pages.no_search', 1023 $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT) 1024 ), 1025 $queryBuilder->expr()->lt( 1026 'pages.doktype', 1027 $queryBuilder->createNamedParameter(200, \PDO::PARAM_INT) 1028 ) 1029 ); 1030 } elseif ($this->searchRootPageIdList >= 0) { 1031 // Collecting all pages IDs in which to search; 1032 // filtering out ALL pages that are not accessible due to restriction containers. 1033 // Does NOT look for "no_search" field! 1034 $siteIdNumbers = GeneralUtility::intExplode(',', $this->searchRootPageIdList); 1035 $pageIdList = []; 1036 foreach ($siteIdNumbers as $rootId) { 1037 $pageIdList[] = $this->getTypoScriptFrontendController()->cObj->getTreeList(-1 * $rootId, 9999); 1038 } 1039 $queryBuilder->andWhere( 1040 $queryBuilder->expr()->in( 1041 'ISEC.page_id', 1042 $queryBuilder->createNamedParameter( 1043 array_unique(GeneralUtility::intExplode(',', implode(',', $pageIdList), true)), 1044 Connection::PARAM_INT_ARRAY 1045 ) 1046 ) 1047 ); 1048 } 1049 // otherwise select all / disable everything 1050 // If any of the ranking sortings are selected, we must make a 1051 // join with the word/rel-table again, because we need to 1052 // calculate ranking based on all search-words found. 1053 if (strpos($this->sortOrder, 'rank_') === 0) { 1054 $queryBuilder 1055 ->from('index_words', 'IW') 1056 ->from('index_rel', 'IR') 1057 ->andWhere( 1058 $queryBuilder->expr()->eq('IW.wid', $queryBuilder->quoteIdentifier('IR.wid')), 1059 $queryBuilder->expr()->eq('ISEC.phash', $queryBuilder->quoteIdentifier('IR.phash')) 1060 ); 1061 switch ($this->sortOrder) { 1062 case 'rank_flag': 1063 // This gives priority to word-position (max-value) so that words in title, keywords, description counts more than in content. 1064 // The ordering is refined with the frequency sum as well. 1065 $queryBuilder 1066 ->addSelectLiteral( 1067 $queryBuilder->expr()->max('IR.flags', 'order_val1'), 1068 $queryBuilder->expr()->sum('IR.freq', 'order_val2') 1069 ) 1070 ->orderBy('order_val1', $this->getDescendingSortOrderFlag()) 1071 ->addOrderBy('order_val2', $this->getDescendingSortOrderFlag()); 1072 break; 1073 case 'rank_first': 1074 // Results in average position of search words on page. 1075 // Must be inversely sorted (low numbers are closer to top) 1076 $queryBuilder 1077 ->addSelectLiteral($queryBuilder->expr()->avg('IR.first', 'order_val')) 1078 ->orderBy('order_val', $this->getDescendingSortOrderFlag(true)); 1079 break; 1080 case 'rank_count': 1081 // Number of words found 1082 $queryBuilder 1083 ->addSelectLiteral($queryBuilder->expr()->sum('IR.count', 'order_val')) 1084 ->orderBy('order_val', $this->getDescendingSortOrderFlag()); 1085 break; 1086 default: 1087 // Frequency sum. I'm not sure if this is the best way to do 1088 // it (make a sum...). Or should it be the average? 1089 $queryBuilder 1090 ->addSelectLiteral($queryBuilder->expr()->sum('IR.freq', 'order_val')) 1091 ->orderBy('order_val', $this->getDescendingSortOrderFlag()); 1092 } 1093 1094 if (!empty($this->wSelClauses)) { 1095 // So, words are combined in an OR statement 1096 // (no "sentence search" should be done here - may deselect results) 1097 $wordSel = $queryBuilder->expr()->orX(); 1098 foreach ($this->wSelClauses as $wSelClause) { 1099 $wordSel->add(QueryHelper::stripLogicalOperatorPrefix($wSelClause)); 1100 } 1101 $queryBuilder->andWhere($wordSel); 1102 } 1103 } else { 1104 // Otherwise, if sorting are done with the pages table or other fields, 1105 // there is no need for joining with the rel/word tables: 1106 switch ($this->sortOrder) { 1107 case 'title': 1108 $queryBuilder->orderBy('IP.item_title', $this->getDescendingSortOrderFlag()); 1109 break; 1110 case 'crdate': 1111 $queryBuilder->orderBy('IP.item_crdate', $this->getDescendingSortOrderFlag()); 1112 break; 1113 case 'mtime': 1114 $queryBuilder->orderBy('IP.item_mtime', $this->getDescendingSortOrderFlag()); 1115 break; 1116 } 1117 } 1118 1119 return $queryBuilder->executeQuery(); 1120 } 1121 1122 /** 1123 * Checking if the resume can be shown for the search result 1124 * (depending on whether the rights are OK) 1125 * ? Should it also check for gr_list "0,-1"? 1126 * 1127 * @param array $row Result row array. 1128 * @return bool Returns TRUE if resume can safely be shown 1129 */ 1130 protected function checkResume($row) 1131 { 1132 // If the record is indexed by an indexing configuration, just show it. 1133 // At least this is needed for external URLs and files. 1134 // For records we might need to extend this - for instance block display if record is access restricted. 1135 if ($row['freeIndexUid']) { 1136 return true; 1137 } 1138 // Evaluate regularly indexed pages based on item_type: 1139 // External media: 1140 $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('index_grlist'); 1141 if ($row['item_type']) { 1142 // For external media we will check the access of the parent page on which the media was linked from. 1143 // "phash_t3" is the phash of the parent TYPO3 page row which initiated the indexing of the documents 1144 // in this section. So, selecting for the grlist records belonging to the parent phash-row where the 1145 // current users gr_list exists will help us to know. If this is NOT found, there is still a theoretical 1146 // possibility that another user accessible page would display a link, so maybe the resume of such a 1147 // document here may be unjustified hidden. But better safe than sorry. 1148 if (!$this->isTableUsed('index_grlist')) { 1149 return false; 1150 } 1151 1152 return (bool)$connection->count( 1153 'phash', 1154 'index_grlist', 1155 [ 1156 'phash' => (int)$row['phash_t3'], 1157 'gr_list' => $this->frontendUserGroupList, 1158 ] 1159 ); 1160 } 1161 // Ordinary TYPO3 pages: 1162 if ((string)$row['gr_list'] !== (string)$this->frontendUserGroupList) { 1163 // Selecting for the grlist records belonging to the phash-row where the current users gr_list exists. 1164 // If it is found it is proof that this user has direct access to the phash-rows content although 1165 // he did not himself initiate the indexing... 1166 if (!$this->isTableUsed('index_grlist')) { 1167 return false; 1168 } 1169 1170 return (bool)$connection->count( 1171 'phash', 1172 'index_grlist', 1173 [ 1174 'phash' => (int)$row['phash'], 1175 'gr_list' => $this->frontendUserGroupList, 1176 ] 1177 ); 1178 } 1179 return true; 1180 } 1181 1182 /** 1183 * Returns "DESC" or "" depending on the settings of the incoming 1184 * highest/lowest result order (piVars['desc']) 1185 * 1186 * @param bool $inverse If TRUE, inverse the order which is defined by piVars['desc'] 1187 * @return string " DESC" or formerly known as tx_indexedsearch_pi->isDescending 1188 */ 1189 protected function getDescendingSortOrderFlag($inverse = false) 1190 { 1191 $desc = $this->descendingSortOrderFlag; 1192 if ($inverse) { 1193 $desc = !$desc; 1194 } 1195 return !$desc ? ' DESC' : ''; 1196 } 1197 1198 /** 1199 * Returns if an item type is a multipage item type 1200 * 1201 * @param string $itemType Item type 1202 * @return bool TRUE if multipage capable 1203 */ 1204 protected function multiplePagesType($itemType) 1205 { 1206 /** @var \TYPO3\CMS\IndexedSearch\FileContentParser $fileContentParser */ 1207 $fileContentParser = $this->externalParsers[$itemType] ?? null; 1208 return is_object($fileContentParser) && $fileContentParser->isMultiplePageExtension($itemType); 1209 } 1210 1211 /** 1212 * md5 integer hash 1213 * Using 7 instead of 8 just because that makes the integers lower than 1214 * 32 bit (28 bit) and so they do not interfere with UNSIGNED integers 1215 * or PHP-versions which has varying output from the hexdec function. 1216 * 1217 * @param string $str String to hash 1218 * @return int Integer interpretation of the md5 hash of input string. 1219 */ 1220 protected function md5inthash($str) 1221 { 1222 return IndexedSearchUtility::md5inthash($str); 1223 } 1224 1225 /** 1226 * Check if the tables provided are configured for usage. 1227 * This becomes necessary for extensions that provide additional database 1228 * functionality like indexed_search_mysql. 1229 * 1230 * @param string $table_list Comma-separated list of tables 1231 * @return bool TRUE if given tables are enabled 1232 */ 1233 protected function isTableUsed($table_list) 1234 { 1235 return IndexedSearchUtility::isTableUsed($table_list); 1236 } 1237 1238 /** 1239 * Returns an object reference to the hook object if any 1240 * 1241 * @param string $functionName Name of the function you want to call / hook key 1242 * @return object|null Hook object, if any. Otherwise NULL. 1243 */ 1244 public function hookRequest($functionName) 1245 { 1246 // Hook: menuConfig_preProcessModMenu 1247 if ($GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['indexed_search']['pi1_hooks'][$functionName] ?? false) { 1248 $hookObj = GeneralUtility::makeInstance($GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['indexed_search']['pi1_hooks'][$functionName]); 1249 if (method_exists($hookObj, $functionName)) { 1250 $hookObj->pObj = $this; 1251 return $hookObj; 1252 } 1253 } 1254 return null; 1255 } 1256 1257 /** 1258 * Search type 1259 * e.g. sentence (20), any part of the word (1) 1260 * 1261 * @return int 1262 */ 1263 public function getSearchType(): int 1264 { 1265 return $this->searchType; 1266 } 1267 1268 /** 1269 * A list of integer which should be root-pages to search from 1270 * 1271 * @return int[] 1272 */ 1273 public function getSearchRootPageIdList() 1274 { 1275 return GeneralUtility::intExplode(',', $this->searchRootPageIdList); 1276 } 1277 1278 /** 1279 * Getter for joinPagesForQuery flag 1280 * enabled through TypoScript 'settings.skipExtendToSubpagesChecking' 1281 * 1282 * @return bool 1283 */ 1284 public function getJoinPagesForQuery() 1285 { 1286 return $this->joinPagesForQuery; 1287 } 1288 1289 /** 1290 * @return \TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController 1291 */ 1292 protected function getTypoScriptFrontendController() 1293 { 1294 return $GLOBALS['TSFE']; 1295 } 1296 1297 /** 1298 * @return TimeTracker 1299 */ 1300 protected function getTimeTracker() 1301 { 1302 return GeneralUtility::makeInstance(TimeTracker::class); 1303 } 1304} 1305