Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.91% covered (success)
90.91%
80 / 88
75.00% covered (warning)
75.00%
12 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
FaqDisplayService
90.91% covered (success)
90.91%
80 / 88
75.00% covered (warning)
75.00%
12 / 16
34.87
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 loadFaq
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 processAnswer
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
5.01
 processQuestion
88.89% covered (success)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 getAttachmentList
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
8.74
 getRenderedCategoryPath
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 getRelatedFaqs
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
2.04
 isExpired
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getNumberOfComments
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCommentsData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAvailableLanguages
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTagsHtml
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRating
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFaqHelper
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 shouldApplyHighlighting
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 processHighlight
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * FAQ Display 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\Faq;
21
22use Exception;
23use League\CommonMark\Environment\Environment;
24use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
25use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
26use League\CommonMark\MarkdownConverter;
27use phpMyFAQ\Attachment\AttachmentException;
28use phpMyFAQ\Attachment\AttachmentFactory;
29use phpMyFAQ\Category;
30use phpMyFAQ\Comments;
31use phpMyFAQ\Configuration;
32use phpMyFAQ\Entity\Comment;
33use phpMyFAQ\Entity\CommentType;
34use phpMyFAQ\Faq;
35use phpMyFAQ\Glossary;
36use phpMyFAQ\Helper\AttachmentHelper;
37use phpMyFAQ\Helper\FaqHelper;
38use phpMyFAQ\Helper\SearchHelper;
39use phpMyFAQ\Rating;
40use phpMyFAQ\Relation;
41use phpMyFAQ\Search\SearchResultSet;
42use phpMyFAQ\Strings;
43use phpMyFAQ\Tags;
44use phpMyFAQ\User\CurrentUser;
45use phpMyFAQ\Utils;
46
47/**
48 * Service class for FAQ display business logic.
49 */
50final class FaqDisplayService
51{
52    private Glossary $glossary;
53
54    private Tags $tags;
55
56    private Relation $relation;
57
58    private Rating $rating;
59
60    private Comments $comments;
61
62    private FaqHelper $faqHelper;
63
64    private Permission $faqPermission;
65
66    private AttachmentHelper $attachmentHelper;
67
68    private MarkdownConverter $markdownConverter;
69
70    /**
71     * @param int[] $currentGroups
72     */
73    public function __construct(
74        private readonly Configuration $configuration,
75        private readonly CurrentUser $currentUser,
76        private readonly array $currentGroups,
77        private readonly Faq $faq,
78        private readonly Category $category,
79    ) {
80        $this->glossary = new Glossary($this->configuration);
81        $this->tags = new Tags($this->configuration);
82        $this->tags->setUser($this->currentUser->getUserId())->setGroups($this->currentGroups);
83        $this->relation = new Relation($this->configuration);
84        $this->rating = new Rating($this->configuration);
85        $this->comments = new Comments($this->configuration);
86        $this->faqHelper = new FaqHelper($this->configuration);
87        $this->faqPermission = new Permission($this->configuration);
88        $this->attachmentHelper = new AttachmentHelper();
89
90        // Setup Markdown converter
91        $config = [
92            'html_input' => 'strip',
93            'allow_unsafe_links' => false,
94        ];
95        $environment = new Environment($config);
96        $environment->addExtension(new CommonMarkCoreExtension());
97        $environment->addExtension(new GithubFlavoredMarkdownExtension());
98
99        $this->markdownConverter = new MarkdownConverter($environment);
100    }
101
102    /**
103     * Load FAQ data by ID or solution ID
104     */
105    public function loadFaq(int $faqId, ?int $solutionId): int
106    {
107        if ($solutionId === null || $solutionId === 0) {
108            $this->faq->getFaq($faqId);
109            return (int) ($this->faq->faqRecord['id'] ?? $faqId);
110        }
111
112        $this->faq->getFaqBySolutionId($solutionId);
113        return (int) ($this->faq->faqRecord['id'] ?? $faqId);
114    }
115
116    /**
117     * Process answer content (Markdown, cleanup, rewrite, glossary)
118     */
119    public function processAnswer(string $currentUrl, ?string $highlight): string
120    {
121        $question = $this->faq->getQuestion((int) $this->faq->faqRecord['id']);
122
123        // Convert Markdown if enabled
124        $answer = (string) ($this->faq->faqRecord['content'] ?? '');
125        if ((bool) $this->configuration->get('main.enableMarkdownEditor')) {
126            $answer = $this->markdownConverter->convert($answer)->getContent();
127        }
128
129        // Cleanup and rewrite
130        $answer = $this->faqHelper->cleanUpContent($answer);
131        $answer = $this->faqHelper->rewriteUrlFragments($answer, $currentUrl);
132        $answer = $this->faqHelper->convertOldInternalLinks($question, $answer);
133        $answer = $this->glossary->insertItemsIntoContent($answer);
134
135        // Apply highlighting if needed
136        if ($this->shouldApplyHighlighting($highlight)) {
137            $processedHighlight = $this->processHighlight((string) $highlight);
138            $searchItems = explode(' ', $processedHighlight);
139
140            foreach ($searchItems as $searchItem) {
141                if (Strings::strlen($searchItem) <= 2) {
142                    continue;
143                }
144
145                $answer = Utils::setHighlightedString($answer, $searchItem);
146            }
147        }
148
149        return $answer;
150    }
151
152    /**
153     * Process question with highlighting
154     */
155    public function processQuestion(?string $highlight): string
156    {
157        $question = $this->faq->getQuestion((int) $this->faq->faqRecord['id']);
158
159        if ($this->shouldApplyHighlighting($highlight)) {
160            $processedHighlight = $this->processHighlight((string) $highlight);
161            $searchItems = explode(' ', $processedHighlight);
162
163            foreach ($searchItems as $searchItem) {
164                if (Strings::strlen($searchItem) <= 2) {
165                    continue;
166                }
167
168                $question = Utils::setHighlightedString($question, $searchItem);
169            }
170        }
171
172        return $question;
173    }
174
175    /**
176     * Get attachment list for FAQ
177     *
178     * @return array<int, array<string, string>>
179     */
180    public function getAttachmentList(int $faqId): array
181    {
182        if (!$this->configuration->get('records.disableAttachments') || $this->faq->faqRecord['active'] !== 'yes') {
183            return [];
184        }
185
186        try {
187            $attList = AttachmentFactory::fetchByRecordId($this->configuration, $faqId);
188            return $this->attachmentHelper->getAttachmentList($attList);
189        } catch (AttachmentException) {
190            return [];
191        }
192    }
193
194    /**
195     * Get a rendered category path for multicategory FAQs
196     */
197    public function getRenderedCategoryPath(int $faqId): string
198    {
199        $renderedCategoryPath = '';
200        $multiCategories = $this->category->getCategoriesFromFaq($faqId);
201
202        if ((is_countable($multiCategories) ? count($multiCategories) : 0) > 1) {
203            foreach ($multiCategories as $multiCategory) {
204                $path = $this->category->getPath((int) $multiCategory['id'], ' &raquo; ', true, 'list-unstyled');
205                if ('' !== trim($path)) {
206                    $renderedCategoryPath .= $path;
207                }
208            }
209        }
210
211        return $renderedCategoryPath;
212    }
213
214    /**
215     * Get related FAQs HTML
216     */
217    public function getRelatedFaqs(int $faqId): string
218    {
219        $searchResultSet = new SearchResultSet($this->currentUser, $this->faqPermission, $this->configuration);
220
221        try {
222            $searchResultSet->reviewResultSet($this->relation->getAllRelatedByQuestion(
223                (string) ($this->faq->faqRecord['title'] ?? ''),
224                (string) ($this->faq->faqRecord['keywords'] ?? ''),
225            ));
226        } catch (Exception) {
227            return '';
228        }
229
230        $searchHelper = new SearchHelper($this->configuration);
231        return $searchHelper->renderRelatedFaqs($searchResultSet, $faqId);
232    }
233
234    /**
235     * Check if FAQ is expired
236     */
237    public function isExpired(): bool
238    {
239        return date(format: 'YmdHis') > (string) ($this->faq->faqRecord['dateEnd'] ?? '');
240    }
241
242    /**
243     * Get a number of comments for FAQ
244     *
245     * @return array<int, int>
246     */
247    public function getNumberOfComments(): array
248    {
249        return $this->comments->getNumberOfComments();
250    }
251
252    /**
253     * Get comments data for FAQ
254     *
255     * @return Comment[]
256     */
257    public function getCommentsData(int $faqId): array
258    {
259        return $this->comments->getCommentsData($faqId, CommentType::FAQ);
260    }
261
262    /**
263     * Get available languages for FAQ
264     *
265     * @return string[]
266     */
267    public function getAvailableLanguages(int $faqId): array
268    {
269        return $this->configuration->getLanguage()->isLanguageAvailable($faqId);
270    }
271
272    /**
273     * Get tags HTML for FAQ
274     */
275    public function getTagsHtml(int $faqId): string
276    {
277        return $this->tags->getAllLinkTagsById($faqId);
278    }
279
280    /**
281     * Get rating for FAQ
282     */
283    public function getRating(int $faqId): string
284    {
285        return $this->rating->get($faqId);
286    }
287
288    /**
289     * Get FAQ helper for additional functionality
290     */
291    public function getFaqHelper(): FaqHelper
292    {
293        return $this->faqHelper;
294    }
295
296    /**
297     * Check if highlighting should be applied
298     */
299    private function shouldApplyHighlighting(?string $highlight): bool
300    {
301        return (
302            !in_array(needle: $highlight, haystack: [null, '/', '<', '>'], strict: true)
303            && Strings::strlen($highlight) > 3
304        );
305    }
306
307    /**
308     * Process highlight string for safe use in search
309     */
310    private function processHighlight(string $highlight): string
311    {
312        $highlight = str_replace(search: "'", replace: 'ยด', subject: $highlight);
313        $highlight = str_replace(
314            search: ['^', '.', '?', '*', '+', '{', '}', '(', ')', '[', ']'],
315            replace: '',
316            subject: $highlight,
317        );
318        return preg_quote($highlight, delimiter: '/');
319    }
320}