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