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