Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.76% covered (success)
82.76%
168 / 203
85.71% covered (success)
85.71%
6 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
SearchHelper
82.76% covered (success)
82.76%
168 / 203
85.71% covered (success)
85.71%
6 / 7
46.80
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setSearchTerm
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createAutoCompleteResult
100.00% covered (success)
100.00%
44 / 44
100.00% covered (success)
100.00%
1 / 1
5
 renderAdminSuggestionResult
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
5
 getSearchResult
63.92% covered (warning)
63.92%
62 / 97
0.00% covered (danger)
0.00%
0 / 1
30.58
 renderScore
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 renderRelatedFaqs
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3/**
4 * Helper class for phpMyFAQ search.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ\Helper
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2009-2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2009-09-07
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Helper;
21
22use Exception;
23use League\CommonMark\Exception\CommonMarkException;
24use phpMyFAQ\Configuration;
25use phpMyFAQ\Faq;
26use phpMyFAQ\Link;
27use phpMyFAQ\Link\Util\TitleSlugifier;
28use phpMyFAQ\Search\SearchResultSet;
29use phpMyFAQ\Strings;
30use phpMyFAQ\Utils;
31use stdClass;
32
33/**
34 * Class SearchHelper
35 *
36 * @package phpMyFAQ\Helper
37 */
38class SearchHelper extends AbstractHelper
39{
40    /**
41     * Search term.
42     */
43    private string $searchTerm = '';
44
45    /**
46     * Constructor.
47     */
48    public function __construct(Configuration $configuration)
49    {
50        $this->configuration = $configuration;
51    }
52
53    /**
54     * Search term setter.
55     *
56     * @param string $searchTerm Search term
57     */
58    public function setSearchTerm(string $searchTerm): void
59    {
60        $this->searchTerm = $searchTerm;
61    }
62
63    /**
64     * Renders the results for Typehead.
65     *
66     * @param SearchResultSet $searchResultSet Result set object
67     */
68    public function createAutoCompleteResult(SearchResultSet $searchResultSet): array
69    {
70        $results = [];
71        $maxResults = (int) $this->configuration->get(item: 'records.numberOfRecordsPerPage');
72        $numOfResults = $searchResultSet->getNumberOfResults();
73
74        if (0 < $numOfResults) {
75            $i = 0;
76            foreach ($searchResultSet->getResultSet() as $result) {
77                if ($i > $maxResults) {
78                    continue;
79                }
80
81                $resultQuestion = (string) $result->question;
82                $question = html_entity_decode($resultQuestion, ENT_QUOTES | ENT_XML1 | ENT_HTML5, encoding: 'UTF-8');
83
84                // Check if this is a custom page result
85                $isCustomPage = ($result->content_type ?? null) === 'page';
86
87                if ($isCustomPage) {
88                    // Build the link to the custom page
89                    $currentUrl = sprintf(
90                        '%spage/%s.html',
91                        $this->configuration->getDefaultUrl(),
92                        (string) $result->slug,
93                    );
94
95                    $link = new Link($currentUrl, $this->configuration);
96                    $link->setTitle($resultQuestion);
97                    $faq = new stdClass();
98                    $faq->category = ''; // Custom pages don't have categories
99                    $faq->question = Utils::chopString($question, 15);
100                    $faq->url = $link->toString();
101
102                    $results[] = $faq;
103                    ++$i;
104                    continue;
105                }
106
107                // Build the link to the faq record
108                $currentUrl = sprintf(
109                    '%scontent/%d/%d/%s/%s.html?highlight=%s',
110                    $this->configuration->getDefaultUrl(),
111                    (int) $result->category_id,
112                    (int) $result->id,
113                    (string) $result->lang,
114                    TitleSlugifier::slug($resultQuestion),
115                    urlencode($this->searchTerm),
116                );
117
118                $link = new Link($currentUrl, $this->configuration);
119                $link->setTitle($resultQuestion);
120                $faq = new stdClass();
121                $faq->category = $this->category()->getPath((int) $result->category_id);
122                $faq->question = Utils::chopString($question, 15);
123                $faq->url = $link->toString();
124
125                $results[] = $faq;
126
127                ++$i;
128            }
129        }
130
131        return $results;
132    }
133
134    /**
135     * Renders the result page for Instant Response.
136     *
137     * @param SearchResultSet $searchResultSet SearchResultSet object
138     */
139    public function renderAdminSuggestionResult(SearchResultSet $searchResultSet): array
140    {
141        $confPerPage = (int) $this->configuration->get(item: 'records.numberOfRecordsPerPage');
142        $numOfResults = $searchResultSet->getNumberOfResults();
143        $results = [];
144
145        if (0 < $numOfResults) {
146            $i = 0;
147            foreach ($searchResultSet->getResultSet() as $result) {
148                if ($i > $confPerPage) {
149                    continue;
150                }
151
152                $faqId = (int) $result->id;
153                $faqLanguage = (string) $result->lang;
154
155                $solutionId = (int) ($result->solution_id ?? 0);
156                if ($solutionId === 0) {
157                    $faq = new Faq($this->configuration);
158                    $solutionId = $faq->getSolutionIdFromId($faqId, $faqLanguage);
159                }
160
161                // Build the link to the faq record
162                $currentUrl = sprintf('%ssolution_id_%d.html', $this->configuration->getDefaultUrl(), $solutionId);
163                $adminUrl = sprintf(
164                    '%sadmin/faq/edit/%d/%s',
165                    $this->configuration->getDefaultUrl(),
166                    $faqId,
167                    $faqLanguage,
168                );
169
170                $results[] = ['url' => $currentUrl, 'question' => (string) $result->question, 'adminUrl' => $adminUrl];
171                ++$i;
172            }
173        }
174
175        return $results;
176    }
177
178    /**
179     * Returns the result page for the main search page
180     *
181     * @return stdClass[]
182     * @throws Exception|CommonMarkException
183     */
184    public function getSearchResult(SearchResultSet $searchResultSet, int $currentPage): array
185    {
186        $results = [];
187        $confPerPage = (int) $this->configuration->get(item: 'records.numberOfRecordsPerPage');
188        $numOfResults = $searchResultSet->getNumberOfResults();
189
190        $lastPage = $currentPage * $confPerPage;
191        $firstPage = $lastPage - $confPerPage;
192
193        if (0 < $numOfResults) {
194            $counter = 0;
195            $displayedCounter = 0;
196            $faqHelper = new FaqHelper($this->configuration);
197
198            foreach ($searchResultSet->getResultSet() as $resultSet) {
199                $result = new stdClass();
200
201                if ($displayedCounter >= $confPerPage) {
202                    break;
203                }
204
205                ++$counter;
206                if ($counter <= $firstPage) {
207                    continue;
208                }
209
210                ++$displayedCounter;
211
212                // Check if this is a custom page result
213                $isCustomPage = ($resultSet->content_type ?? null) === 'page';
214
215                if ($isCustomPage) {
216                    // Handle custom page results
217                    $question = Utils::chopString(Strings::htmlentities((string) $resultSet->question), 15);
218                    $answerPreview = $faqHelper->renderAnswerPreview((string) $resultSet->answer, 20);
219
220                    $searchTerm = str_replace(
221                        search: ['^', '.', '?', '*', '+', '{', '}', '(', ')', '[', ']', '"'],
222                        replace: '',
223                        subject: $this->searchTerm,
224                    );
225                    $searchTerm = preg_quote($searchTerm, delimiter: '/');
226                    $searchItems = explode(' ', $searchTerm);
227
228                    if (
229                        true === $this->configuration->get(item: 'search.enableHighlighting')
230                        && Strings::strlen($searchItems[0]) > 1
231                    ) {
232                        foreach ($searchItems as $searchItem) {
233                            if (Strings::strlen($searchItem) <= 2) {
234                                continue;
235                            }
236
237                            $question = Utils::setHighlightedString($question, $searchItem);
238                            $answerPreview = Utils::setHighlightedString($answerPreview, $searchItem);
239                        }
240                    }
241
242                    // Build the link to the custom page
243                    $currentUrl = sprintf(
244                        '%spage/%s.html',
245                        $this->configuration->getDefaultUrl(),
246                        (string) $resultSet->slug,
247                    );
248
249                    $oLink = new Link($currentUrl, $this->configuration);
250                    $oLink->setTitle((string) $resultSet->question);
251
252                    $result->renderedScore = $this->renderScore(
253                        (is_numeric($resultSet->score ?? null) ? (float) $resultSet->score : 0.0) * 33,
254                    );
255                    $result->question = $question;
256                    $result->path = ''; // Custom pages don't have category paths
257                    $result->url = $oLink->toString();
258                    $result->answerPreview = $answerPreview;
259                    $result->isCustomPage = true;
260                    $results[] = $result;
261                    continue;
262                }
263
264                // Handle FAQ results
265                // Set language for the current category to fetch the correct category name
266                $this->category()->setLanguage((string) $resultSet->lang);
267
268                $categoryInfo = $this->category()->getCategoriesFromFaq((int) $resultSet->id);
269                $categoryInfo = array_values($categoryInfo); //Reset the array keys
270                $question = Utils::chopString(Strings::htmlentities((string) $resultSet->question), 15);
271                $answerPreview = Strings::htmlentities($faqHelper->renderAnswerPreview(
272                    (string) $resultSet->answer,
273                    20,
274                ));
275
276                $searchTerm = str_replace(
277                    search: ['^', '.', '?', '*', '+', '{', '}', '(', ')', '[', ']', '"'],
278                    replace: '',
279                    subject: $this->searchTerm,
280                );
281                $searchTerm = preg_quote($searchTerm, delimiter: '/');
282                $searchItems = explode(' ', $searchTerm);
283
284                if (
285                    true === $this->configuration->get(item: 'search.enableHighlighting')
286                    && Strings::strlen($searchItems[0]) > 1
287                ) {
288                    foreach ($searchItems as $searchItem) {
289                        if (Strings::strlen($searchItem) <= 2) {
290                            continue;
291                        }
292
293                        $question = Utils::setHighlightedString($question, $searchItem);
294                        $answerPreview = Utils::setHighlightedString($answerPreview, $searchItem);
295                    }
296                }
297
298                // Build the link to the faq record
299                $currentUrl = sprintf(
300                    '%scontent/%d/%d/%s/%s.html?highlight=%s',
301                    $this->configuration->getDefaultUrl(),
302                    (int) $resultSet->category_id,
303                    (int) $resultSet->id,
304                    (string) $resultSet->lang,
305                    TitleSlugifier::slug((string) $resultSet->question),
306                    urlencode($searchTerm),
307                );
308
309                $oLink = new Link($currentUrl, $this->configuration);
310                $oLink->setTitle((string) $resultSet->question);
311
312                $path = ($categoryInfo[0]['id'] ?? null) !== null
313                    ? $this->category()->getPath((int) $categoryInfo[0]['id'])
314                    : '';
315
316                $result->renderedScore = $this->renderScore(
317                    (is_numeric($resultSet->score ?? null) ? (float) $resultSet->score : 0.0) * 33,
318                );
319                $result->question = $question;
320                $result->path = $path;
321                $result->url = $oLink->toString();
322                $result->answerPreview = $answerPreview;
323
324                $results[] = $result;
325            }
326        }
327
328        return $results;
329    }
330
331    /**
332     * Renders the scoring stars
333     */
334    private function renderScore(float $relevance = 0): string
335    {
336        $html = sprintf('<span title="%01.2f%%">', $relevance);
337        $emptyStar = '<i aria-hidden="true" class="bi bi-star-o"></i>';
338        $fullStar = '<i aria-hidden="true" class="bi bi-star"></i>';
339
340        $html .= match (true) {
341            0 === (int) $relevance => $emptyStar . $emptyStar . $emptyStar,
342            $relevance < 33 => $fullStar . $emptyStar . $emptyStar,
343            $relevance < 66 => $fullStar . $fullStar . $emptyStar,
344            default => $fullStar . $fullStar . $fullStar,
345        };
346
347        return $html . '</span> ';
348    }
349
350    public function renderRelatedFaqs(SearchResultSet $searchResultSet, int $recordId): string
351    {
352        $html = '';
353        $numOfResults = $searchResultSet->getNumberOfResults();
354
355        if ($numOfResults > 0) {
356            $html .= '<ul class="list-unstyled">';
357            $counter = 0;
358            foreach ($searchResultSet->getResultSet() as $result) {
359                if ($counter >= 5) {
360                    continue;
361                }
362
363                if ($recordId === (int) $result->id) {
364                    continue;
365                }
366
367                ++$counter;
368
369                $relatedQuestion = Strings::htmlentities((string) $result->question);
370                $url = sprintf(
371                    '%scontent/%d/%d/%s/%s.html',
372                    $this->configuration->getDefaultUrl(),
373                    (int) $result->category_id,
374                    (int) $result->id,
375                    (string) $result->lang,
376                    TitleSlugifier::slug((string) $result->question),
377                );
378                $link = new Link($url, $this->configuration);
379                $link->setTitle($relatedQuestion);
380                $link->text = $relatedQuestion;
381                $link->tooltip = $relatedQuestion;
382                $link->class = 'text-decoration-none';
383                $html .= '<li><i class="bi bi-question-circle"></i> ' . $link->toHtmlAnchor() . '</li>';
384            }
385
386            $html .= '</ul>';
387        }
388
389        return $html;
390    }
391}