SearchController.php 70.3 KB
Newer Older
1
<?php
2

3
/*
4
 * This file is part of the TYPO3 CMS project.
5
 *
6
7
8
 * It is free software; you can redistribute it and/or modify it under
 * the terms of the GNU General Public License, either version 2
 * of the License, or any later version.
9
 *
10
11
 * For the full copyright and license information, please read the
 * LICENSE.txt file that was distributed with this source code.
12
 *
13
14
 * The TYPO3 project - inspiring people to share!
 */
15

16
17
namespace TYPO3\CMS\IndexedSearch\Controller;

18
use Psr\Http\Message\ResponseInterface;
19
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
20
use TYPO3\CMS\Core\Context\Context;
21
use TYPO3\CMS\Core\Database\Connection;
22
use TYPO3\CMS\Core\Database\ConnectionPool;
23
use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer;
24
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
25
use TYPO3\CMS\Core\Exception\Page\RootLineException;
26
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
27
use TYPO3\CMS\Core\Html\HtmlParser;
28
use TYPO3\CMS\Core\Site\SiteFinder;
29
use TYPO3\CMS\Core\Type\File\ImageInfo;
30
use TYPO3\CMS\Core\TypoScript\TypoScriptService;
31
use TYPO3\CMS\Core\Utility\GeneralUtility;
32
use TYPO3\CMS\Core\Utility\MathUtility;
33
use TYPO3\CMS\Core\Utility\PathUtility;
34
use TYPO3\CMS\Core\Utility\RootlineUtility;
35
use TYPO3\CMS\Extbase\Annotation as Extbase;
36
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
37
use TYPO3\CMS\Extbase\Utility\LocalizationUtility;
38
39
40
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
use TYPO3\CMS\IndexedSearch\Domain\Repository\IndexSearchRepository;
use TYPO3\CMS\IndexedSearch\Lexer;
41
use TYPO3\CMS\IndexedSearch\Utility\IndexedSearchUtility;
42

43
44
45
/**
 * Index search frontend
 *
46
 * Creates a search form for indexed search. Indexing must be enabled
47
 * for this to make sense.
48
 * @internal This class is a specific controller implementation and is not considered part of the Public TYPO3 API.
49
 */
50
class SearchController extends ActionController
51
52
53
54
55
56
{
    /**
     * previously known as $this->piVars['sword']
     *
     * @var string
     */
57
    protected $sword = '';
58

59
60
61
    /**
     * @var array
     */
62
    protected $searchWords = [];
63

64
65
66
67
    /**
     * @var array
     */
    protected $searchData;
68

69
70
71
72
73
74
75
76
77
78
    /**
     * This is the id of the site root.
     * This value may be a comma separated list of integer (prepared for this)
     * Root-page PIDs to search in (rl0 field where clause, see initialize() function)
     *
     * If this value is set to less than zero (eg. -1) searching will happen
     * in ALL of the page tree with no regard to branches at all.
     * @var int|string
     */
    protected $searchRootPageIdList = 0;
79

80
81
82
83
    /**
     * @var int
     */
    protected $defaultResultNumber = 10;
84

85
86
87
88
89
    /**
     * @var int[]
     */
    protected $availableResultsNumbers = [];

90
91
92
93
94
    /**
     * Search repository
     *
     * @var \TYPO3\CMS\IndexedSearch\Domain\Repository\IndexSearchRepository
     */
95
    protected $searchRepository;
96

97
98
99
100
101
102
    /**
     * Lexer object
     *
     * @var \TYPO3\CMS\IndexedSearch\Lexer
     */
    protected $lexerObj;
103

104
105
106
107
    /**
     * External parser objects
     * @var array
     */
108
    protected $externalParsers = [];
109

110
111
112
113
114
    /**
     * Will hold the first row in result - used to calculate relative hit-ratings.
     *
     * @var array
     */
115
    protected $firstRow = [];
116

117
118
119
120
121
    /**
     * sys_domain records
     *
     * @var array
     */
122
    protected $domainRecords = [];
123

124
125
126
127
128
    /**
     * Required fe_groups memberships for display of a result.
     *
     * @var array
     */
129
    protected $requiredFrontendUsergroups = [];
130

131
132
133
134
135
    /**
     * Page tree sections for search result.
     *
     * @var array
     */
136
    protected $resultSections = [];
137

138
139
140
141
142
    /**
     * Caching of page path
     *
     * @var array
     */
143
    protected $pathCache = [];
144

145
146
147
148
149
    /**
     * Storage of icons
     *
     * @var array
     */
150
    protected $iconFileNameCache = [];
151

152
    /**
153
     * Indexer configuration, coming from TYPO3's system configuration for EXT:indexed_search
154
155
156
     *
     * @var array
     */
157
    protected $indexerConfig = [];
158

159
160
161
162
163
164
    /**
     * Flag whether metaphone search should be enabled
     *
     * @var bool
     */
    protected $enableMetaphoneSearch = false;
165

166
    /**
167
     * @var \TYPO3\CMS\Core\TypoScript\TypoScriptService
168
169
     */
    protected $typoScriptService;
170

171
    /**
172
     * @param \TYPO3\CMS\Core\TypoScript\TypoScriptService $typoScriptService
173
     */
174
    public function injectTypoScriptService(TypoScriptService $typoScriptService)
175
176
177
    {
        $this->typoScriptService = $typoScriptService;
    }
178

179
180
181
182
183
184
    /**
     * sets up all necessary object for searching
     *
     * @param array $searchData The incoming search parameters
     * @return array Search parameters
     */
185
    public function initialize($searchData = [])
186
187
    {
        if (!is_array($searchData)) {
188
            $searchData = [];
189
        }
190

191
192
193
194
195
        // check if TypoScript is loaded
        if (!isset($this->settings['results'])) {
            $this->redirect('noTypoScript');
        }

196
197
198
199
200
201
202
203
        // Sets availableResultsNumbers - has to be called before request settings are read to avoid DoS attack
        $this->availableResultsNumbers = array_filter(GeneralUtility::intExplode(',', $this->settings['blind']['numberOfResults']));

        // Sets default result number if at least one availableResultsNumbers exists
        if (isset($this->availableResultsNumbers[0])) {
            $this->defaultResultNumber = $this->availableResultsNumbers[0];
        }

204
        $this->loadSettings();
205

206
207
208
209
        // setting default values
        if (is_array($this->settings['defaultOptions'])) {
            $searchData = array_merge($this->settings['defaultOptions'], $searchData);
        }
210
        // if "languageUid" was set to "current", take the current site language
211
        if (($searchData['languageUid'] ?? '') === 'current') {
212
213
214
            $searchData['languageUid'] = GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('language', 'id', 0);
        }

215
        // Indexer configuration from Extension Manager interface:
216
        $this->indexerConfig = GeneralUtility::makeInstance(ExtensionConfiguration::class)->get('indexed_search');
217
218
219
        $this->enableMetaphoneSearch = (bool)$this->indexerConfig['enableMetaphoneSearch'];
        $this->initializeExternalParsers();
        // If "_sections" is set, this value overrides any existing value.
220
        if ($searchData['_sections'] ?? false) {
221
222
223
            $searchData['sections'] = $searchData['_sections'];
        }
        // If "_sections" is set, this value overrides any existing value.
224
        if (($searchData['_freeIndexUid'] ?? '') !== '' && ($searchData['_freeIndexUid'] ?? '') !== '_') {
225
226
            $searchData['freeIndexUid'] = $searchData['_freeIndexUid'];
        }
227
        $searchData['numberOfResults'] = $this->getNumberOfResults($searchData['numberOfResults'] ?? 0);
228
        // This gets the search-words into the $searchWordArray
229
        $this->setSword($searchData['sword'] ?? '');
230
        // Add previous search words to current
231
        if (($searchData['sword_prev_include'] ?? false) && ($searchData['sword_prev'] ?? false)) {
232
            $this->setSword(trim($searchData['sword_prev']) . ' ' . $this->getSword());
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
        }
        // This is the id of the site root.
        // This value may be a commalist of integer (prepared for this)
        $this->searchRootPageIdList = (int)$GLOBALS['TSFE']->config['rootLine'][0]['uid'];
        // Setting the list of root PIDs for the search. Notice, these page IDs MUST
        // have a TypoScript template with root flag on them! Basically this list is used
        // to select on the "rl0" field and page ids are registered as "rl0" only if
        // a TypoScript template record with root flag is there.
        // This happens AFTER the use of $this->searchRootPageIdList above because
        // the above will then fetch the menu for the CURRENT site - regardless
        // of this kind of searching here. Thus a general search will lookup in
        // the WHOLE database while a specific section search will take the current sections.
        if ($this->settings['rootPidList']) {
            $this->searchRootPageIdList = implode(',', GeneralUtility::intExplode(',', $this->settings['rootPidList']));
        }
248
        $this->searchRepository = GeneralUtility::makeInstance(IndexSearchRepository::class);
249
250
        $this->searchRepository->initialize($this->settings, $searchData, $this->externalParsers, $this->searchRootPageIdList);
        $this->searchData = $searchData;
251
252
        // $this->searchData is used in $this->getSearchWords
        $this->searchWords = $this->getSearchWords($searchData['defaultOperand']);
253
254
255
256
257
258
        // Calling hook for modification of initialized content
        if ($hookObj = $this->hookRequest('initialize_postProc')) {
            $hookObj->initialize_postProc();
        }
        return $searchData;
    }
259

260
261
262
263
    /**
     * Performs the search, the display and writing stats
     *
     * @param array $search the search parameters, an associative array
264
     * @Extbase\IgnoreValidation("search")
265
     */
266
    public function searchAction($search = []): ResponseInterface
267
268
269
270
271
272
273
274
275
276
    {
        $searchData = $this->initialize($search);
        // Find free index uid:
        $freeIndexUid = $searchData['freeIndexUid'];
        if ($freeIndexUid == -2) {
            $freeIndexUid = $this->settings['defaultFreeIndexUidList'];
        } elseif (!isset($searchData['freeIndexUid'])) {
            // index configuration is disabled
            $freeIndexUid = -1;
        }
277
278
279
280
281

        if (!empty($searchData['extendedSearch'])) {
            $this->view->assignMultiple($this->processExtendedSearchParameters());
        }

282
        $indexCfgs = GeneralUtility::intExplode(',', $freeIndexUid);
283
        $resultsets = [];
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
        foreach ($indexCfgs as $freeIndexUid) {
            // Get result rows
            if ($hookObj = $this->hookRequest('getResultRows')) {
                $resultData = $hookObj->getResultRows($this->searchWords, $freeIndexUid);
            } else {
                $resultData = $this->searchRepository->doSearch($this->searchWords, $freeIndexUid);
            }
            // Display search results
            if ($hookObj = $this->hookRequest('getDisplayResults')) {
                $resultsets[$freeIndexUid] = $hookObj->getDisplayResults($this->searchWords, $resultData, $freeIndexUid);
            } else {
                $resultsets[$freeIndexUid] = $this->getDisplayResults($this->searchWords, $resultData, $freeIndexUid);
            }
            // Create header if we are searching more than one indexing configuration
            if (count($indexCfgs) > 1) {
                if ($freeIndexUid > 0) {
300
301
302
                    $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
                        ->getQueryBuilderForTable('index_config');
                    $indexCfgRec = $queryBuilder
303
                        ->select('title')
304
                        ->from('index_config')
305
306
307
308
309
310
                        ->where(
                            $queryBuilder->expr()->eq(
                                'uid',
                                $queryBuilder->createNamedParameter($freeIndexUid, \PDO::PARAM_INT)
                            )
                        )
311
                        ->execute()
312
                        ->fetchAssociative();
313
314
                    $categoryTitle = LocalizationUtility::translate('indexingConfigurationHeader.' . $freeIndexUid, 'IndexedSearch');
                    $categoryTitle = $categoryTitle ?: $indexCfgRec['title'];
315
316
317
318
319
320
                } else {
                    $categoryTitle = LocalizationUtility::translate('indexingConfigurationHeader.' . $freeIndexUid, 'IndexedSearch');
                }
                $resultsets[$freeIndexUid]['categoryTitle'] = $categoryTitle;
            }
            // Write search statistics
321
            $this->writeSearchStat($this->searchWords ?: []);
322
323
324
325
        }
        $this->view->assign('resultsets', $resultsets);
        $this->view->assign('searchParams', $searchData);
        $this->view->assign('searchWords', $this->searchWords);
326

327
        return $this->htmlResponse();
328
    }
329

330
331
332
333
334
335
336
337
338
339
340
341
342
343
    /****************************************
     * functions to make the result rows and result sets
     * ready for the output
     ***************************************/
    /**
     * Compiles the HTML display of the incoming array of result rows.
     *
     * @param array $searchWords Search words array (for display of text describing what was searched for)
     * @param array $resultData Array with result rows, count, first row.
     * @param int $freeIndexUid Pointing to which indexing configuration you want to search in. -1 means no filtering. 0 means only regular indexed content.
     * @return array
     */
    protected function getDisplayResults($searchWords, $resultData, $freeIndexUid = -1)
    {
344
        $result = [
345
            'count' => $resultData['count'] ?? 0,
346
            'searchWords' => $searchWords
347
        ];
348
349
350
351
352
353
354
355
356
357
        // Perform display of result rows array
        if ($resultData) {
            // Set first selected row (for calculation of ranking later)
            $this->firstRow = $resultData['firstRow'];
            // Result display here
            $result['rows'] = $this->compileResultRows($resultData['resultRows'], $freeIndexUid);
            $result['affectedSections'] = $this->resultSections;
            // Browsing box
            if ($resultData['count']) {
                // could we get this in the view?
358
                if ($this->searchData['group'] === 'sections' && $freeIndexUid <= 0) {
359
                    $resultSectionsCount = count($this->resultSections);
360
                    $result['sectionText'] = sprintf(LocalizationUtility::translate('result.' . ($resultSectionsCount > 1 ? 'inNsections' : 'inNsection'), 'IndexedSearch') ?? '', $resultSectionsCount);
361
362
363
364
                }
            }
        }
        // Print a message telling which words in which sections we searched for
365
        if (strpos($this->searchData['sections'], 'rl') === 0) {
366
            $result['searchedInSectionInfo'] = (LocalizationUtility::translate('result.inSection', 'IndexedSearch') ?? '') . ' "' . $this->getPathFromPageId((int)substr($this->searchData['sections'], 4)) . '"';
367
        }
368
369
370
371
372

        if ($hookObj = $this->hookRequest('getDisplayResults_postProc')) {
            $result = $hookObj->getDisplayResults_postProc($result);
        }

373
374
        return $result;
    }
375

376
377
378
379
380
381
    /**
     * Takes the array with resultrows as input and returns the result-HTML-code
     * Takes the "group" var into account: Makes a "section" or "flat" display.
     *
     * @param array $resultRows Result rows
     * @param int $freeIndexUid Pointing to which indexing configuration you want to search in. -1 means no filtering. 0 means only regular indexed content.
382
     * @return array the result rows with additional information
383
384
385
     */
    protected function compileResultRows($resultRows, $freeIndexUid = -1)
    {
386
        $finalResultRows = [];
387
388
        // Transfer result rows to new variable,
        // performing some mapping of sub-results etc.
389
        $newResultRows = [];
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
        foreach ($resultRows as $row) {
            $id = md5($row['phash_grouping']);
            if (is_array($newResultRows[$id])) {
                // swapping:
                if (!$newResultRows[$id]['show_resume'] && $row['show_resume']) {
                    // Remove old
                    $subrows = $newResultRows[$id]['_sub'];
                    unset($newResultRows[$id]['_sub']);
                    $subrows[] = $newResultRows[$id];
                    // Insert new:
                    $newResultRows[$id] = $row;
                    $newResultRows[$id]['_sub'] = $subrows;
                } else {
                    $newResultRows[$id]['_sub'][] = $row;
                }
            } else {
                $newResultRows[$id] = $row;
            }
        }
        $resultRows = $newResultRows;
410
        $this->resultSections = [];
411
        if ($freeIndexUid <= 0 && $this->searchData['group'] === 'sections') {
412
            $rl2flag = strpos($this->searchData['sections'], 'rl') === 0;
413
            $sections = [];
414
415
416
417
            foreach ($resultRows as $row) {
                $id = $row['rl0'] . '-' . $row['rl1'] . ($rl2flag ? '-' . $row['rl2'] : '');
                $sections[$id][] = $row;
            }
418
            $this->resultSections = [];
419
420
421
422
423
424
425
426
427
428
429
430
            foreach ($sections as $id => $resultRows) {
                $rlParts = explode('-', $id);
                if ($rlParts[2]) {
                    $theId = $rlParts[2];
                    $theRLid = 'rl2_' . $rlParts[2];
                } elseif ($rlParts[1]) {
                    $theId = $rlParts[1];
                    $theRLid = 'rl1_' . $rlParts[1];
                } else {
                    $theId = $rlParts[0];
                    $theRLid = '0';
                }
431
                $sectionName = $this->getPathFromPageId((int)$theId);
432
433
434
435
436
437
438
439
                $sectionName = ltrim($sectionName, '/');
                if (!trim($sectionName)) {
                    $sectionTitleLinked = LocalizationUtility::translate('result.unnamedSection', 'IndexedSearch') . ':';
                } else {
                    $onclick = 'document.forms[\'tx_indexedsearch\'][\'tx_indexedsearch_pi2[search][_sections]\'].value=' . GeneralUtility::quoteJSvalue($theRLid) . ';document.forms[\'tx_indexedsearch\'].submit();return false;';
                    $sectionTitleLinked = '<a href="#" onclick="' . htmlspecialchars($onclick) . '">' . $sectionName . ':</a>';
                }
                $resultRowsCount = count($resultRows);
440
                $this->resultSections[$id] = [$sectionName, $resultRowsCount];
441
                // Add section header
442
                $finalResultRows[] = [
443
444
445
446
                    'isSectionHeader' => true,
                    'numResultRows' => $resultRowsCount,
                    'sectionId' => $id,
                    'sectionTitle' => $sectionTitleLinked
447
                ];
448
449
450
451
452
453
454
455
456
457
458
459
460
                // Render result rows
                foreach ($resultRows as $row) {
                    $finalResultRows[] = $this->compileSingleResultRow($row);
                }
            }
        } else {
            // flat mode or no sections at all
            foreach ($resultRows as $row) {
                $finalResultRows[] = $this->compileSingleResultRow($row);
            }
        }
        return $finalResultRows;
    }
461

462
463
464
465
466
    /**
     * This prints a single result row, including a recursive call for subrows.
     *
     * @param array $row Search result row
     * @param int $headerOnly 1=Display only header (for sub-rows!), 2=nothing at all
467
     * @return array the result row with additional information
468
469
470
471
472
473
474
475
     */
    protected function compileSingleResultRow($row, $headerOnly = 0)
    {
        $specRowConf = $this->getSpecialConfigurationForResultRow($row);
        $resultData = $row;
        $resultData['headerOnly'] = $headerOnly;
        $resultData['CSSsuffix'] = $specRowConf['CSSsuffix'] ? '-' . $specRowConf['CSSsuffix'] : '';
        if ($this->multiplePagesType($row['item_type'])) {
476
            $dat = json_decode($row['static_page_arguments'], true);
477
478
479
480
481
482
483
484
            $pp = explode('-', $dat['key']);
            if ($pp[0] != $pp[1]) {
                $resultData['titleaddition'] = ', ' . LocalizationUtility::translate('result.page', 'IndexedSearch') . ' ' . $dat['key'];
            } else {
                $resultData['titleaddition'] = ', ' . LocalizationUtility::translate('result.pages', 'IndexedSearch') . ' ' . $pp[0];
            }
        }
        $title = $resultData['item_title'] . $resultData['titleaddition'];
485
        $title = GeneralUtility::fixed_lgd_cs($title, $this->settings['results.']['titleCropAfter'], $this->settings['results.']['titleCropSignifier']);
486
487
488
489
490
491
492
493
494
495
496
497
        // If external media, link to the media-file instead.
        if ($row['item_type']) {
            if ($row['show_resume']) {
                // Can link directly.
                $targetAttribute = '';
                if ($GLOBALS['TSFE']->config['config']['fileTarget']) {
                    $targetAttribute = ' target="' . htmlspecialchars($GLOBALS['TSFE']->config['config']['fileTarget']) . '"';
                }
                $title = '<a href="' . htmlspecialchars($row['data_filename']) . '"' . $targetAttribute . '>' . htmlspecialchars($title) . '</a>';
            } else {
                // Suspicious, so linking to page instead...
                $copiedRow = $row;
498
                unset($copiedRow['static_page_arguments']);
499
                $title = $this->linkPageATagWrap(
500
                    $title,
501
502
                    $this->linkPage($row['page_id'], $copiedRow)
                );
503
504
505
506
            }
        } else {
            // Else the page:
            // Prepare search words for markup in content:
507
            $markUpSwParams = [];
508
509
            if ($this->settings['forwardSearchWordsInResultLink']['_typoScriptNodeValue']) {
                if ($this->settings['forwardSearchWordsInResultLink']['no_cache']) {
510
                    $markUpSwParams = ['no_cache' => 1];
511
512
513
514
515
                }
                foreach ($this->searchWords as $d) {
                    $markUpSwParams['sword_list'][] = $d['sword'];
                }
            }
516
            $title = $this->linkPageATagWrap(
517
                $title,
518
519
                $this->linkPage($row['data_page_id'], $row, $markUpSwParams)
            );
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
        }
        $resultData['title'] = $title;
        $resultData['icon'] = $this->makeItemTypeIcon($row['item_type'], '', $specRowConf);
        $resultData['rating'] = $this->makeRating($row);
        $resultData['description'] = $this->makeDescription(
            $row,
            (bool)!($this->searchData['extResume'] && !$headerOnly),
            $this->settings['results.']['summaryCropAfter']
        );
        $resultData['language'] = $this->makeLanguageIndication($row);
        $resultData['size'] = GeneralUtility::formatSize($row['item_size']);
        $resultData['created'] = $row['item_crdate'];
        $resultData['modified'] = $row['item_mtime'];
        $pI = parse_url($row['data_filename']);
        if ($pI['scheme']) {
            $targetAttribute = '';
            if ($GLOBALS['TSFE']->config['config']['fileTarget']) {
                $targetAttribute = ' target="' . htmlspecialchars($GLOBALS['TSFE']->config['config']['fileTarget']) . '"';
            }
539
540
            $resultData['pathTitle'] = $row['data_filename'];
            $resultData['pathUri'] = $row['data_filename'];
541
542
543
544
545
            $resultData['path'] = '<a href="' . htmlspecialchars($row['data_filename']) . '"' . $targetAttribute . '>' . htmlspecialchars($row['data_filename']) . '</a>';
        } else {
            $pathId = $row['data_page_id'] ?: $row['page_id'];
            $pathMP = $row['data_page_id'] ? $row['data_page_mp'] : '';
            $pathStr = $this->getPathFromPageId($pathId, $pathMP);
546
547
548
549
550
            $pathLinkData = $this->linkPage(
                $pathId,
                [
                    'data_page_type' => $row['data_page_type'],
                    'data_page_mp' => $pathMP,
551
552
                    'sys_language_uid' => $row['sys_language_uid'],
                    'static_page_arguments' => $row['static_page_arguments']
553
554
555
556
557
558
559
                ]
            );

            $resultData['pathTitle'] = $pathStr;
            $resultData['pathUri'] = $pathLinkData['uri'];
            $resultData['path'] = $this->linkPageATagWrap($pathStr, $pathLinkData);

560
561
            // check if the access is restricted
            if (is_array($this->requiredFrontendUsergroups[$pathId]) && !empty($this->requiredFrontendUsergroups[$pathId])) {
562
563
564
565
                $lockedIcon = GeneralUtility::getFileAbsFileName('EXT:indexed_search/Resources/Public/Icons/FileTypes/locked.gif');
                $lockedIcon = PathUtility::getAbsoluteWebPath($lockedIcon);
                $resultData['access'] = '<img src="' . htmlspecialchars($lockedIcon) . '"'
                    . ' width="12" height="15" vspace="5" title="'
566
                    . sprintf(LocalizationUtility::translate('result.memberGroups', 'IndexedSearch') ?? '', implode(',', array_unique($this->requiredFrontendUsergroups[$pathId])))
567
568
569
570
571
572
                    . '" alt="" />';
            }
        }
        // If there are subrows (eg. subpages in a PDF-file or if a duplicate page
        // is selected due to user-login (phash_grouping))
        if (is_array($row['_sub'])) {
573
            $resultData['subresults'] = [];
574
575
576
577
578
579
580
581
582
583
584
585
            if ($this->multiplePagesType($row['item_type'])) {
                $resultData['subresults']['header'] = LocalizationUtility::translate('result.otherMatching', 'IndexedSearch');
                foreach ($row['_sub'] as $subRow) {
                    $resultData['subresults']['items'][] = $this->compileSingleResultRow($subRow, 1);
                }
            } else {
                $resultData['subresults']['header'] = LocalizationUtility::translate('result.otherMatching', 'IndexedSearch');
                $resultData['subresults']['info'] = LocalizationUtility::translate('result.otherPageAsWell', 'IndexedSearch');
            }
        }
        return $resultData;
    }
586

587
588
589
590
591
592
593
594
595
596
597
598
    /**
     * Returns configuration from TypoScript for result row based
     * on ID / location in page tree!
     *
     * @param array $row Result row
     * @return array Configuration array
     */
    protected function getSpecialConfigurationForResultRow($row)
    {
        $pathId = $row['data_page_id'] ?: $row['page_id'];
        $pathMP = $row['data_page_id'] ? $row['data_page_mp'] : '';
        $specConf = $this->settings['specialConfiguration']['0'];
599
600
        try {
            $rl = GeneralUtility::makeInstance(RootlineUtility::class, $pathId, $pathMP)->get();
601
602
603
604
605
606
607
            foreach ($rl as $dat) {
                if (is_array($this->settings['specialConfiguration'][$dat['uid']])) {
                    $specConf = $this->settings['specialConfiguration'][$dat['uid']];
                    $specConf['_pid'] = $dat['uid'];
                    break;
                }
            }
608
609
        } catch (RootLineException $e) {
            // do nothing
610
611
612
        }
        return $specConf;
    }
613

614
615
616
617
618
619
620
621
622
    /**
     * Return the rating-HTML code for the result row. This makes use of the $this->firstRow
     *
     * @param array $row Result row array
     * @return string String showing ranking value
     * @todo can this be a ViewHelper?
     */
    protected function makeRating($row)
    {
623
        $default = ' ';
624
625
626
627
        switch ((string)$this->searchData['sortOrder']) {
            case 'rank_count':
                return $row['order_val'] . ' ' . LocalizationUtility::translate('result.ratingMatches', 'IndexedSearch');
            case 'rank_first':
628
                return ceil(MathUtility::forceIntegerInRange(255 - $row['order_val'], 1, 255) / 255 * 100) . '%';
629
630
631
632
633
            case 'rank_flag':
                if ($this->firstRow['order_val2']) {
                    // (3 MSB bit, 224 is highest value of order_val1 currently)
                    $base = $row['order_val1'] * 256;
                    // 15-3 MSB = 12
634
                    $freqNumber = $row['order_val2'] / $this->firstRow['order_val2'] * 2 ** 12;
635
636
637
                    $total = MathUtility::forceIntegerInRange($base + $freqNumber, 0, 32767);
                    return ceil(log($total) / log(32767) * 100) . '%';
                }
638
                return $default;
639
640
641
642
643
644
645
646
647
            case 'rank_freq':
                $max = 10000;
                $total = MathUtility::forceIntegerInRange($row['order_val'], 0, $max);
                return ceil(log($total) / log($max) * 100) . '%';
            case 'crdate':
                return $GLOBALS['TSFE']->cObj->calcAge($GLOBALS['EXEC_TIME'] - $row['item_crdate'], 0);
            case 'mtime':
                return $GLOBALS['TSFE']->cObj->calcAge($GLOBALS['EXEC_TIME'] - $row['item_mtime'], 0);
            default:
648
                return $default;
649
650
        }
    }
651

652
653
654
655
656
657
658
659
660
661
662
663
664
665
    /**
     * Returns the HTML code for language indication.
     *
     * @param array $row Result row
     * @return string HTML code for result row.
     */
    protected function makeLanguageIndication($row)
    {
        $output = '&nbsp;';
        // If search result is a TYPO3 page:
        if ((string)$row['item_type'] === '0') {
            // If TypoScript is used to render the flag:
            if (is_array($this->settings['flagRendering'])) {
                /** @var \TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer $cObj */
666
                $cObj = GeneralUtility::makeInstance(ContentObjectRenderer::class);
667
668
669
670
671
672
673
                $cObj->setCurrentVal($row['sys_language_uid']);
                $typoScriptArray = $this->typoScriptService->convertPlainArrayToTypoScriptArray($this->settings['flagRendering']);
                $output = $cObj->cObjGetSingle($this->settings['flagRendering']['_typoScriptNodeValue'], $typoScriptArray);
            }
        }
        return $output;
    }
674

675
676
677
678
679
680
    /**
     * Return icon for file extension
     *
     * @param string $imageType File extension / item type
     * @param string $alt Title attribute value in icon.
     * @param array $specRowConf TypoScript configuration specifically for search result.
681
     * @return string HTML <img> tag for icon
682
683
684
685
686
687
688
689
690
691
692
693
694
     */
    public function makeItemTypeIcon($imageType, $alt, $specRowConf)
    {
        // Build compound key if item type is 0, iconRendering is not used
        // and specialConfiguration.[pid].pageIcon was set in TS
        if ($imageType === '0' && $specRowConf['_pid'] && is_array($specRowConf['pageIcon']) && !is_array($this->settings['iconRendering'])) {
            $imageType .= ':' . $specRowConf['_pid'];
        }
        if (!isset($this->iconFileNameCache[$imageType])) {
            $this->iconFileNameCache[$imageType] = '';
            // If TypoScript is used to render the icon:
            if (is_array($this->settings['iconRendering'])) {
                /** @var \TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer $cObj */
695
                $cObj = GeneralUtility::makeInstance(ContentObjectRenderer::class);
696
697
698
699
700
701
                $cObj->setCurrentVal($imageType);
                $typoScriptArray = $this->typoScriptService->convertPlainArrayToTypoScriptArray($this->settings['iconRendering']);
                $this->iconFileNameCache[$imageType] = $cObj->cObjGetSingle($this->settings['iconRendering']['_typoScriptNodeValue'], $typoScriptArray);
            } else {
                // Default creation / finding of icon:
                $icon = '';
702
                if ($imageType === '0' || strpos($imageType, '0:') === 0) {
703
704
705
706
707
708
709
710
711
712
713
                    if (is_array($specRowConf['pageIcon'])) {
                        $this->iconFileNameCache[$imageType] = $GLOBALS['TSFE']->cObj->cObjGetSingle('IMAGE', $specRowConf['pageIcon']);
                    } else {
                        $icon = 'EXT:indexed_search/Resources/Public/Icons/FileTypes/pages.gif';
                    }
                } elseif ($this->externalParsers[$imageType]) {
                    $icon = $this->externalParsers[$imageType]->getIcon($imageType);
                }
                if ($icon) {
                    $fullPath = GeneralUtility::getFileAbsFileName($icon);
                    if ($fullPath) {
714
715
716
717
718
719
720
721
                        $imageInfo = GeneralUtility::makeInstance(ImageInfo::class, $fullPath);
                        $iconPath = PathUtility::stripPathSitePrefix($fullPath);
                        $this->iconFileNameCache[$imageType] = $imageInfo->getWidth()
                            ? '<img src="' . $iconPath
                              . '" width="' . $imageInfo->getWidth()
                              . '" height="' . $imageInfo->getHeight()
                              . '" title="' . htmlspecialchars($alt) . '" alt="" />'
                            : '';
722
723
724
725
726
727
                    }
                }
            }
        }
        return $this->iconFileNameCache[$imageType];
    }
728

729
730
731
732
733
734
735
736
737
738
739
    /**
     * Returns the resume for the search-result.
     *
     * @param array $row Search result row
     * @param bool $noMarkup If noMarkup is FALSE, then the index_fulltext table is used to select the content of the page, split it with regex to display the search words in the text.
     * @param int $length String length
     * @return string HTML string
     * @todo overwork this
     */
    protected function makeDescription($row, $noMarkup = false, $length = 180)
    {
740
741
        $markedSW = '';
        $outputStr = '';
742
743
        if ($row['show_resume']) {
            if (!$noMarkup) {
744
745
746
747
                $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('index_fulltext');
                $ftdrow = $queryBuilder
                    ->select('*')
                    ->from('index_fulltext')
748
749
750
751
752
753
                    ->where(
                        $queryBuilder->expr()->eq(
                            'phash',
                            $queryBuilder->createNamedParameter($row['phash'], \PDO::PARAM_INT)
                        )
                    )
754
                    ->execute()
755
                    ->fetchAssociative();
756
                if ($ftdrow !== false) {
757
758
759
760
761
762
                    // Cut HTTP references after some length
                    $content = preg_replace('/(http:\\/\\/[^ ]{' . $this->settings['results.']['hrefInSummaryCropAfter'] . '})([^ ]+)/i', '$1...', $ftdrow['fulltextdata']);
                    $markedSW = $this->markupSWpartsOfString($content);
                }
            }
            if (!trim($markedSW)) {
763
                $outputStr = GeneralUtility::fixed_lgd_cs($row['item_description'], $length, $this->settings['results.']['summaryCropSignifier']);
764
765
766
767
768
769
770
771
                $outputStr = htmlspecialchars($outputStr);
            }
            $output = $outputStr ?: $markedSW;
        } else {
            $output = '<span class="noResume">' . LocalizationUtility::translate('result.noResume', 'IndexedSearch') . '</span>';
        }
        return $output;
    }
772

773
774
775
776
777
778
779
780
781
782
783
784
    /**
     * Marks up the search words from $this->searchWords in the $str with a color.
     *
     * @param string $str Text in which to find and mark up search words. This text is assumed to be UTF-8 like the search words internally is.
     * @return string Processed content
     */
    protected function markupSWpartsOfString($str)
    {
        $htmlParser = GeneralUtility::makeInstance(HtmlParser::class);
        // Init:
        $str = str_replace('&nbsp;', ' ', $htmlParser->bidir_htmlspecialchars($str, -1));
        $str = preg_replace('/\\s\\s+/', ' ', $str);
785
        $swForReg = [];
786
787
788
789
790
791
792
        // Prepare search words for regex:
        foreach ($this->searchWords as $d) {
            $swForReg[] = preg_quote($d['sword'], '/');
        }
        $regExString = '(' . implode('|', $swForReg) . ')';
        // Split and combine:
        $parts = preg_split('/' . $regExString . '/i', ' ' . $str . ' ', 20000, PREG_SPLIT_DELIM_CAPTURE);
793
        $parts = $parts ?: [];
794
795
        // Constants:
        $summaryMax = $this->settings['results.']['markupSW_summaryMax'];
796
797
        $postPreLgd = (int)$this->settings['results.']['markupSW_postPreLgd'];
        $postPreLgd_offset = (int)$this->settings['results.']['markupSW_postPreLgd_offset'];
798
        $divider = $this->settings['results.']['markupSW_divider'];
799
800
801
        $occurrences = (count($parts) - 1) / 2;
        if ($occurrences) {
            $postPreLgd = MathUtility::forceIntegerInRange($summaryMax / $occurrences, $postPreLgd, $summaryMax / 2);
802
803
804
        }
        // Variable:
        $summaryLgd = 0;
805
        $output = [];
806
807
808
809
        // Shorten in-between strings:
        foreach ($parts as $k => $strP) {
            if ($k % 2 == 0) {
                // Find length of the summary part:
810
                $strLen = mb_strlen($parts[$k], 'utf-8');
811
812
813
814
815
                $output[$k] = $parts[$k];
                // Possibly shorten string:
                if (!$k) {
                    // First entry at all (only cropped on the frontside)
                    if ($strLen > $postPreLgd) {
816
                        $output[$k] = $divider . preg_replace('/^[^[:space:]]+[[:space:]]/', '', GeneralUtility::fixed_lgd_cs($parts[$k], -($postPreLgd - $postPreLgd_offset)));
817
                    }
818
                } elseif ($summaryLgd > $summaryMax || !isset($parts[$k + 1])) {
819
820
                    // In case summary length is exceed OR if there are no more entries at all:
                    if ($strLen > $postPreLgd) {
821
822
                        $output[$k] = preg_replace('/[[:space:]][^[:space:]]+$/', '', GeneralUtility::fixed_lgd_cs(
                            $parts[$k],
Benni Mack's avatar
Benni Mack committed
823
                            $postPreLgd - $postPreLgd_offset
824
                        )) . $divider;
825
826
827
                    }
                } else {
                    if ($strLen > $postPreLgd * 2) {
828
829
                        $output[$k] = preg_replace('/[[:space:]][^[:space:]]+$/', '', GeneralUtility::fixed_lgd_cs(
                            $parts[$k],
Benni Mack's avatar
Benni Mack committed
830
                            $postPreLgd - $postPreLgd_offset
831
                        )) . $divider . preg_replace('/^[^[:space:]]+[[:space:]]/', '', GeneralUtility::fixed_lgd_cs($parts[$k], -($postPreLgd - $postPreLgd_offset)));
832
833
                    }
                }
834
                $summaryLgd += mb_strlen($output[$k], 'utf-8');
835
836
837
838
839
840
841
                // Protect output:
                $output[$k] = htmlspecialchars($output[$k]);
                // If summary lgd is exceed, break the process:
                if ($summaryLgd > $summaryMax) {
                    break;
                }
            } else {
842
                $summaryLgd += mb_strlen($strP, 'utf-8');
843
844
845
846
847
848
                $output[$k] = '<strong class="tx-indexedsearch-redMarkup">' . htmlspecialchars($parts[$k]) . '</strong>';
            }
        }
        // Return result:
        return implode('', $output);
    }
849

850
    /**
851
     * Write statistics information to database for the search operation if there was at least one search word.
852
853
854
     *
     * @param array $searchWords Search Word array
     */
855
    protected function writeSearchStat(array $searchWords): void
856
    {
857
        if (empty($this->getSword()) && empty($searchWords)) {
858
859
            return;
        }
860
861
862
        $entries = [];
        foreach ($searchWords as $val) {
            $entries[] = [
863
                mb_substr($val['sword'], 0, 50),
864
                // Time stamp
865
                $GLOBALS['EXEC_TIME'],
866
                // search page id for indexed search stats
867
                $GLOBALS['TSFE']->id
868
869
            ];
        }
870
871
872
873
874
875
876
877
        GeneralUtility::makeInstance(ConnectionPool::class)
            ->getConnectionForTable('index_stat_word')
            ->bulkInsert(
                'index_stat_word',
                $entries,
                [ 'word', 'tstamp', 'pageid' ],
                [ \PDO::PARAM_STR, \PDO::PARAM_INT, \PDO::PARAM_INT ]
            );
878
    }
879

880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
    /**
     * Splits the search word input into an array where each word is represented by an array with key "sword"
     * holding the search word and key "oper" holding the SQL operator (eg. AND, OR)
     *
     * Only words with 2 or more characters are accepted
     * Max 200 chars total
     * Space is used to split words, "" can be used search for a whole string
     * AND, OR and NOT are prefix words, overruling the default operator
     * +/|/- equals AND, OR and NOT as operators.
     * All search words are converted to lowercase.
     *
     * $defOp is the default operator. 1=OR, 0=AND
     *
     * @param bool $defaultOperator If TRUE, the default operator will be OR, not AND
     * @return array Search words if any found
     */
    protected function getSearchWords($defaultOperator)
    {
898
899
        // Shorten search-word string to max 200 bytes - shortening the string here is only a run-away feature!
        $searchWords = mb_substr($this->getSword(), 0, 200);
900
        // Convert to UTF-8 + conv. entities (was also converted during indexing!)
901
902
903
904
        if ($GLOBALS['TSFE']->metaCharset && $GLOBALS['TSFE']->metaCharset !== 'utf-8') {
            $searchWords = mb_convert_encoding($searchWords, 'utf-8', $GLOBALS['TSFE']->metaCharset);
            $searchWords = html_entity_decode($searchWords);
        }
905
906
907
908
909
910
        $sWordArray = false;
        if ($hookObj = $this->hookRequest('getSearchWords')) {
            $sWordArray = $hookObj->getSearchWords_splitSWords($searchWords, $defaultOperator);
        } else {
            // sentence
            if ($this->searchData['searchType'] == 20) {
911
912
                $sWordArray = [
                    [
913
914
                        'sword' => trim($searchWords),
                        'oper' => 'AND'
915
916
                    ]
                ];
917
918
919
            } else {
                // case-sensitive. Defines the words, which will be
                // operators between words
920
921
922
923
                $operatorTranslateTable = [
                    ['+', 'AND'],
                    ['|', 'OR'],
                    ['-', 'AND NOT'],
924
                    // Add operators for various languages
925
                    // Converts the operators to lowercase
926
927
928
                    [mb_strtolower(LocalizationUtility::translate('localizedOperandAnd', 'IndexedSearch') ?? '', 'utf-8'), 'AND'],
                    [mb_strtolower(LocalizationUtility::translate('localizedOperandOr', 'IndexedSearch') ?? '', 'utf-8'), 'OR'],
                    [mb_strtolower(LocalizationUtility::translate('localizedOperandNot', 'IndexedSearch') ?? '', 'utf-8'), 'AND NOT']
929
                ];
930
                $swordArray = IndexedSearchUtility::getExplodedSearchString($searchWords, $defaultOperator == 1 ? 'OR' : 'AND', $operatorTranslateTable);
931
932
933
934
935
936
937
                if (is_array($swordArray)) {
                    $sWordArray = $this->procSearchWordsByLexer($swordArray);
                }
            }
        }
        return $sWordArray;
    }
938

939
940
941
942
943
944
945
946
947
    /**
     * Post-process the search word array so it will match the words that was indexed (including case-folding if any)
     * If any words are splitted into multiple words (eg. CJK will be!) the operator of the main word will remain.
     *
     * @param array $searchWords Search word array
     * @return array Search word array, processed through lexer
     */
    protected function procSearchWordsByLexer($searchWords)
    {
948
        $newSearchWords = [];
949
        // Init lexer (used to post-processing of search words)
950
951
        $lexerObjectClassName = ($GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['indexed_search']['lexer'] ?? false) ? $GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['indexed_search']['lexer'] : Lexer::class;

952
953
954
        /** @var Lexer $lexer */
        $lexer = GeneralUtility::makeInstance($lexerObjectClassName);
        $this->lexerObj = $lexer;
955
956
        // Traverse the search word array
        foreach ($searchWords as $wordDef) {
957
            // No space in word (otherwise it might be a sentence in quotes like "there is").
958
959
960
961
962
            if (strpos($wordDef['sword'], ' ') === false) {
                // Split the search word by lexer:
                $res = $this->lexerObj->split2Words($wordDef['sword']);
                // Traverse lexer result and add all words again:
                foreach ($res as $word) {
963
                    $newSearchWords[] = [
964
965
                        'sword' => $word,
                        'oper' => $wordDef['oper']
966
                    ];
967
968
969
970
971
972
973
                }
            } else {
                $newSearchWords[] = $wordDef;
            }
        }
        return $newSearchWords;
    }
974

975
976
977
978
    /**
     * Sort options about the search form
     *
     * @param array $search The search data / params
979
     * @Extbase\IgnoreValidation("search")
980
     */
981
    public function formAction($search = []): ResponseInterface
982
983
984
    {
        $searchData = $this->initialize($search);
        // Adding search field value
985
        $this->view->assign('sword', $this->getSword());
986
        // Extended search
987
        if (!empty($searchData['extendedSearch'])) {
988
            $this->view->assignMultiple($this->processExtendedSearchParameters());
989
990
        }
        $this->view->assign('searchParams', $searchData);
991

992
        return $this->htmlResponse();
993
    }
994

995
996
997
    /**
     * TypoScript was not loaded
     */
998
    public function noTypoScriptAction(): ResponseInterface
999
    {
1000
        return $this->htmlResponse();
1001
1002
    }

1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
    /****************************************
     * building together the available options for every dropdown
     ***************************************/
    /**
     * get the values for the "type" selector
     *
     * @return array Associative array with options
     */
    protected function getAllAvailableSearchTypeOptions()
    {
1013
1014
        $allOptions = [];
        $types = [0, 1, 2, 3, 10, 20];
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
        $blindSettings = $this->settings['blind'];
        if (!$blindSettings['searchType']) {
            foreach ($types as $typeNum) {
                $allOptions[$typeNum] = LocalizationUtility::translate('searchTypes.' . $typeNum, 'IndexedSearch');
            }
        }
        // Remove this option if metaphone search is disabled)
        if (!$this->enableMetaphoneSearch) {
            unset($allOptions[10]);
        }
        // disable single entries by TypoScript
        $allOptions = $this->removeOptionsFromOptionList($allOptions, $blindSettings['searchType']);
        return $allOptions;
    }
1029

1030
1031
1032
1033
1034
1035
1036
    /**
     * get the values for the "defaultOperand" selector
     *
     * @return array Associative array with options
     */
    protected function getAllAvailableOperandsOptions()
    {
1037
        $allOptions = [];
1038
1039
        $blindSettings = $this->settings['blind'];
        if (!$blindSettings['defaultOperand']) {
1040
            $allOptions = [
1041
1042
                0 => LocalizationUtility::translate('defaultOperands.0', 'IndexedSearch'),
                1 => LocalizationUtility::translate('defaultOperands.1', 'IndexedSearch')
1043
            ];
1044
1045
1046
1047
1048
        }
        // disable single entries by TypoScript
        $allOptions = $this->removeOptionsFromOptionList($allOptions, $blindSettings['defaultOperand']);
        return $allOptions;
    }
1049

1050
1051
1052
1053
1054
1055
1056
    /**
     * get the values for the "media type" selector
     *
     * @return array Associative array with options
     */
    protected function getAllAvailableMediaTypesOptions