Lines 90.90% 80 / 88
Methods 75.00% 12 / 16
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 17 / 17 100.00% 1 / 1 1
 loadFaq 100.00% 5 / 5 100.00% 1 / 1 3
 processAnswer 93.75% 15 / 16 0.00% 0 / 1 5.01
 processQuestion 88.88% 8 / 9 0.00% 0 / 1 4.02
 getAttachmentList 33.33% 2 / 6 0.00% 0 / 1 8.74
 getRenderedCategoryPath 100.00% 8 / 8 100.00% 1 / 1 5
 getRelatedFaqs 77.77% 7 / 9 0.00% 0 / 1 2.04
 isExpired 100.00% 1 / 1 100.00% 1 / 1 1
 getNumberOfComments 100.00% 1 / 1 100.00% 1 / 1 1
 getCommentsData 100.00% 1 / 1 100.00% 1 / 1 1
 getAvailableLanguages 100.00% 1 / 1 100.00% 1 / 1 1
 getTagsHtml 100.00% 1 / 1 100.00% 1 / 1 1
 getRating 100.00% 1 / 1 100.00% 1 / 1 1
 getFaqHelper 100.00% 1 / 1 100.00% 1 / 1 1
 shouldApplyHighlighting 100.00% 4 / 4 100.00% 1 / 1 2
 processHighlight 100.00% 7 / 7 100.00% 1 / 1 1
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}