Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.60% covered (success)
89.60%
155 / 173
55.56% covered (warning)
55.56%
5 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
SearchService
89.60% covered (success)
89.60%
155 / 173
55.56% covered (warning)
55.56%
5 / 9
41.80
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 processSearch
98.53% covered (success)
98.53%
67 / 68
0.00% covered (danger)
0.00%
0 / 1
13
 handleTagSearch
92.68% covered (success)
92.68%
38 / 41
0.00% covered (danger)
0.00%
0 / 1
8.03
 calculateRelatedTags
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 handleFulltextSearch
76.67% covered (warning)
76.67%
23 / 30
0.00% covered (danger)
0.00%
0 / 1
6.46
 getFormattedSearchResults
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 renderTagList
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 shouldRedirectToSolutionId
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 getSolutionIdRedirectUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * Search Service
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
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 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     2026-01-02
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Search;
21
22use Exception;
23use League\CommonMark\Exception\CommonMarkException;
24use phpMyFAQ\Category;
25use phpMyFAQ\Configuration;
26use phpMyFAQ\Faq;
27use phpMyFAQ\Faq\Permission;
28use phpMyFAQ\Helper\SearchHelper;
29use phpMyFAQ\Helper\TagsHelper;
30use phpMyFAQ\Language\Plurals;
31use phpMyFAQ\Pagination;
32use phpMyFAQ\Pagination\PaginationTemplates;
33use phpMyFAQ\Pagination\UrlConfig;
34use phpMyFAQ\Search;
35use phpMyFAQ\Tags;
36use phpMyFAQ\User\CurrentUser;
37
38/**
39 * Service class for search-related business logic.
40 */
41final class SearchService
42{
43    private Faq $faq;
44
45    private Category $category;
46
47    private Tags $tags;
48
49    private Search $faqSearch;
50
51    private SearchResultSet $searchResultSet;
52
53    /**
54     * @param int[] $currentGroups
55     */
56    public function __construct(
57        private readonly Configuration $configuration,
58        private readonly CurrentUser $currentUser,
59        private readonly array $currentGroups,
60    ) {
61        $this->faq = new Faq($this->configuration);
62        $this->faq->setUser($this->currentUser->getUserId());
63        $this->faq->setGroups($this->currentGroups);
64
65        $this->category = new Category($this->configuration, $this->currentGroups);
66
67        $this->tags = new Tags($this->configuration);
68        $this->tags->setUser($this->currentUser->getUserId())->setGroups($this->currentGroups);
69
70        $this->faqSearch = new Search($this->configuration);
71
72        $faqPermission = new Permission($this->configuration);
73        $this->searchResultSet = new SearchResultSet($this->currentUser, $faqPermission, $this->configuration);
74    }
75
76    /**
77     * Processes search input and returns search results with metadata.
78     *
79     * @throws CommonMarkException
80     * @return array<string, mixed>
81     */
82    public function processSearch(
83        string $inputSearchTerm,
84        string $inputTag,
85        int|string $inputCategory,
86        bool $allLanguages,
87        int $page,
88    ): array {
89        $tagSearch = false;
90        $numOfResults = 0;
91        $searchResults = [];
92        $relTags = '';
93        $tags = [];
94        $baseUrl = sprintf(
95            '%ssearch.html?search=%s&seite=%d%s&pmf-search-category=%d',
96            $this->configuration->getDefaultUrl(),
97            urlencode($inputSearchTerm),
98            $page,
99            $allLanguages ? '&langs=all' : '',
100            $inputCategory,
101        );
102
103        // Handle tag search
104        if ($inputTag !== '') {
105            $tagSearchData = $this->handleTagSearch($inputTag, $page, $allLanguages);
106            $tagSearch = true;
107            $numOfResults = (int) ($tagSearchData['numOfResults'] ?? 0);
108            $searchResults = $tagSearchData['searchResults'];
109            $relTags = (string) ($tagSearchData['relTags'] ?? '');
110            $tagsData = $tagSearchData['tags'] ?? [];
111            $tags = [];
112            foreach (is_array($tagsData) ? $tagsData : [] as $tagId => $tagName) {
113                $tags[(int) $tagId] = (string) $tagName;
114            }
115            $baseUrl = (string) ($tagSearchData['baseUrl'] ?? '');
116        }
117
118        // Handle fulltext search
119        if ($inputSearchTerm !== '') {
120            $fulltextData = $this->handleFulltextSearch($inputSearchTerm, (int) $inputCategory, $allLanguages, $page);
121            $searchResults = $fulltextData['searchResults'];
122            $numOfResults = (int) ($fulltextData['numOfResults'] ?? 0);
123            $baseUrl = (string) ($fulltextData['baseUrl'] ?? '');
124        }
125
126        // Change category value
127        $inputCategory = '%' === $inputCategory ? 0 : $inputCategory;
128
129        // Number of results
130        if ($numOfResults === 0) {
131            $numOfResults = $this->searchResultSet->getNumberOfResults();
132        }
133
134        // Build category tree
135        if ($allLanguages) {
136            $this->category->transform(0);
137        }
138
139        $this->category->buildCategoryTree();
140
141        // Get most popular searches
142        $mostPopularSearchData = $this->faqSearch->getMostPopularSearches((int) $this->configuration->get(
143            'search.numberSearchTerms',
144        ));
145
146        // Setup pagination
147        $confPerPage = (int) $this->configuration->get('records.numberOfRecordsPerPage');
148        $totalPages = (int) ceil($numOfResults / $confPerPage);
149
150        $faqPagination = new Pagination(
151            baseUrl: $baseUrl,
152            total: $numOfResults,
153            perPage: $confPerPage,
154            templates: new PaginationTemplates(
155                layout: '<ul class="pagination justify-content-center">{LAYOUT_CONTENT}</ul>',
156            ),
157            urlConfig: new UrlConfig(pageParamName: 'seite'),
158        );
159
160        // Get formatted search results
161        $formattedSearchResults = [];
162        if ($numOfResults > 0 && $inputSearchTerm !== '') {
163            $formattedSearchResults = $this->getFormattedSearchResults($inputSearchTerm, $page);
164        }
165
166        return [
167            'tagSearch' => $tagSearch,
168            'selectedCategory' => $inputCategory,
169            'categories' => $this->category->getCategoryTree(),
170            'numberOfSearchResults' => $numOfResults,
171            'totalPages' => $totalPages,
172            'currentPage' => $page,
173            'searchTerm' => $inputSearchTerm,
174            'searchTags' => $tagSearch ? $this->renderTagList($tags) : '',
175            'searchResults' => $formattedSearchResults !== [] ? $formattedSearchResults : $searchResults,
176            'allLanguages' => $allLanguages,
177            'mostPopularSearches' => $mostPopularSearchData,
178            'relatedTags' => $relTags,
179            'tagList' => $this->tags->getPopularTags(),
180            'pagination' => $faqPagination->render(),
181        ];
182    }
183
184    /**
185     * Handles tag-based search.
186     *
187     * @throws CommonMarkException
188     * @return array<string, mixed>
189     */
190    private function handleTagSearch(string $inputTag, int $page, bool $allLanguages): array
191    {
192        $tags = [];
193        $tagIds = explode(',', $inputTag);
194        $relTags = '';
195        $searchResults = [];
196
197        $tagsHelper = new TagsHelper();
198        $tagsHelper->setTaggingIds($tagIds);
199
200        foreach ($tagIds as $tagId) {
201            if (array_key_exists($tagId, $tags)) {
202                continue;
203            }
204            if (!is_numeric($tagId)) {
205                continue;
206            }
207
208            $tags[(int) $tagId] = $this->tags->getTagNameById((int) $tagId);
209        }
210
211        $recordIds = $this->tags->getFaqsByIntersectionTags($tags);
212
213        $numOfResults = 0;
214        if (count($recordIds) > 0) {
215            $relatedTags = $this->calculateRelatedTags($recordIds, $tags);
216
217            uasort($relatedTags, static fn($a, $b): int => $b - $a);
218            $numTags = 0;
219
220            foreach ($relatedTags as $tagId => $relevance) {
221                $relTags .= $tagsHelper->renderRelatedTag($tagId, $this->tags->getTagNameById($tagId), $relevance);
222                if ($numTags++ > 20) {
223                    break;
224                }
225            }
226
227            $numOfResults = count($recordIds);
228
229            // Apply pagination to record IDs for tag search
230            $confPerPage = (int) $this->configuration->get('records.numberOfRecordsPerPage');
231            $first = ($page - 1) * $confPerPage;
232            $paginatedRecordIds = array_slice($recordIds, $first, $confPerPage);
233
234            $searchResults = $this->faq->getFaqsDataByIds($paginatedRecordIds, 'fd.id', 'ASC', false);
235        }
236
237        // Set base URL scheme for tag search
238        $baseUrl = sprintf(
239            '%ssearch.html?tagging_id=%s&seite=%d%s',
240            $this->configuration->getDefaultUrl(),
241            $inputTag,
242            $page,
243            $allLanguages ? '&langs=all' : '',
244        );
245
246        return [
247            'tags' => $tags,
248            'relTags' => $relTags,
249            'searchResults' => $searchResults,
250            'numOfResults' => $numOfResults,
251            'baseUrl' => $baseUrl,
252        ];
253    }
254
255    /**
256     * Calculates related tags for the given record IDs.
257     *
258     * @param array<int> $recordIds
259     * @param array<int, string> $tags
260     * @return array<int, int>
261     */
262    private function calculateRelatedTags(array $recordIds, array $tags): array
263    {
264        $relatedTags = [];
265
266        foreach ($recordIds as $recordId) {
267            $resultTags = $this->tags->getAllTagsById((int) $recordId);
268            foreach (array_keys($resultTags) as $resultTagId) {
269                if (array_key_exists($resultTagId, $tags)) {
270                    continue;
271                }
272
273                $relatedTags[$resultTagId] = ($relatedTags[$resultTagId] ?? 0) + 1;
274            }
275        }
276
277        return $relatedTags;
278    }
279
280    /**
281     * Handles fulltext search.
282     *
283     * @return array<string, mixed>
284     */
285    private function handleFulltextSearch(
286        string $inputSearchTerm,
287        int $inputCategory,
288        bool $allLanguages,
289        int $page,
290    ): array {
291        $searchResults = [];
292
293        $inputSearchTerm = $this->configuration->getDb()->escape(strip_tags($inputSearchTerm));
294
295        $this->faqSearch->setCategory($this->category);
296        $this->faqSearch->setCategoryId($inputCategory);
297
298        try {
299            $searchResults = $this->faqSearch->search($inputSearchTerm, $allLanguages);
300        } catch (Exception $exception) {
301            $this->configuration->getLogger()->debug($exception->getMessage());
302        }
303
304        foreach ($searchResults as $faqKey => $faqValue) {
305            // Database drivers differ in column typing: mysqli returns strings,
306            // SQLite returns ints. Cast so the strict int type hint on
307            // getFaqResult() does not break full-text search on MySQL/MariaDB.
308            $checkedFaq = $this->faq->getFaqResult((int) $faqValue->id, (string) $faqValue->lang);
309            if (0 === $this->configuration->getDb()->numRows($checkedFaq)) {
310                unset($searchResults[$faqKey]);
311            }
312        }
313
314        $this->searchResultSet->reviewResultSet($searchResults);
315
316        $inputSearchTerm = stripslashes($inputSearchTerm);
317        $numOfResults = $this->searchResultSet->getNumberOfResults();
318
319        try {
320            $this->faqSearch->logSearchTerm($inputSearchTerm);
321        } catch (Exception $exception) {
322            $this->configuration->getLogger()->debug($exception->getMessage());
323        }
324
325        // Set the base URL scheme for fulltext search
326        $baseUrl = sprintf(
327            '%ssearch.html?search=%s&seite=%d%s&pmf-search-category=%d',
328            $this->configuration->getDefaultUrl(),
329            urlencode($inputSearchTerm),
330            $page,
331            $allLanguages ? '&langs=all' : '',
332            $inputCategory,
333        );
334
335        return [
336            'searchResults' => $searchResults,
337            'numOfResults' => $numOfResults,
338            'baseUrl' => $baseUrl,
339        ];
340    }
341
342    /**
343     * Gets formatted search results using SearchHelper.
344     *
345     * @return \stdClass[]
346     */
347    private function getFormattedSearchResults(string $searchTerm, int $page): array
348    {
349        $searchHelper = new SearchHelper($this->configuration);
350        $searchHelper->setSearchTerm($searchTerm);
351        $searchHelper->setCategory($this->category);
352        $searchHelper->setPlurals(new Plurals());
353
354        try {
355            return $searchHelper->getSearchResult($this->searchResultSet, $page);
356        } catch (Exception|CommonMarkException) {
357            return [];
358        }
359    }
360
361    /**
362     * Renders the tag list for display.
363     *
364     * @param array<int, string> $tags
365     */
366    private function renderTagList(array $tags): string
367    {
368        $tagsHelper = new TagsHelper();
369        $tagsHelper->setTaggingIds(array_keys($tags));
370        return $tagsHelper->renderTagList($tags);
371    }
372
373    /**
374     * Checks if search should redirect to solution ID.
375     */
376    public function shouldRedirectToSolutionId(string $inputSearchTerm, int $numOfResults): bool
377    {
378        return (
379            is_numeric($inputSearchTerm)
380            && PMF_SOLUTION_ID_START_VALUE <= $inputSearchTerm
381            && 0 < $numOfResults
382            && (bool) $this->configuration->get('search.searchForSolutionId')
383        );
384    }
385
386    /**
387     * Gets the solution ID redirect URL.
388     */
389    public function getSolutionIdRedirectUrl(string $inputSearchTerm): string
390    {
391        return $this->configuration->getDefaultUrl() . 'solution_id_' . $inputSearchTerm . '.html';
392    }
393}