Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
80.41% covered (success)
80.41%
119 / 148
77.78% covered (warning)
77.78%
7 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
CategoryHelper
80.41% covered (success)
80.41%
119 / 148
77.78% covered (warning)
77.78%
7 / 9
42.70
0.00% covered (danger)
0.00%
0 / 1
 renderCategoryTree
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 gatherCategoryData
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getCategoryTreeData
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 buildCategoryNodes
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
5
 buildCategoryList
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
6
 buildAvailableCategoryTranslationsList
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 normalizeCategoryTree
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 getModerators
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
156
 renderAvailableTranslationsOptions
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * Helper class for phpMyFAQ categories.
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 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 phpMyFAQ\Category;
23use phpMyFAQ\Category\Language\CategoryLanguageService;
24use phpMyFAQ\Category\Relation;
25use phpMyFAQ\Core\Exception;
26use phpMyFAQ\Language\LanguageCodes;
27use phpMyFAQ\Link;
28use phpMyFAQ\Link\Util\TitleSlugifier;
29use phpMyFAQ\Permission\MediumPermission;
30use phpMyFAQ\Strings;
31use phpMyFAQ\Translation;
32use phpMyFAQ\User;
33
34/**
35 * Class CategoryHelper
36 *
37 * @package phpMyFAQ\Helper
38 */
39class CategoryHelper extends AbstractHelper
40{
41    /**
42     * Renders the static tree with the number of records.
43     */
44    public function renderCategoryTree(int $parentId = 0): string
45    {
46        [$categoryTree, $normalizedCategoryNumbers, $aggregatedNumbers] = $this->gatherCategoryData();
47
48        if ([] !== $categoryTree) {
49            return sprintf('<ul class="pmf-category-overview">%s</ul>', $this->buildCategoryList(
50                $categoryTree,
51                $parentId,
52                $aggregatedNumbers,
53                $normalizedCategoryNumbers,
54            ));
55        }
56
57        $languagesAvailable = $this->getCategory()->getCategoryLanguagesTranslated($parentId);
58        return sprintf(
59            '<p>%s</p><ul class="pmf-category-overview">%s</ul>',
60            Translation::getString('msgCategoryMissingButTranslationAvailable'),
61            $this->buildAvailableCategoryTranslationsList($languagesAvailable),
62        );
63    }
64
65    /**
66     * Gathers the raw category tree and FAQ-count arrays shared by the HTML
67     * renderer and the structured-data builder.
68     *
69     * @return array{0: array<int, array<string, mixed>>, 1: array<int, array<string, mixed>>, 2: array<int, int>}
70     */
71    private function gatherCategoryData(): array
72    {
73        $categoryRelation = new Relation($this->getConfiguration(), $this->getCategory());
74        $categoryRelation->setGroups($this->getCategory()->getGroups());
75
76        $categoryTree = $this->getCategory()->getOrderedCategories();
77        $categoryNumbers = $categoryRelation->getCategoryWithFaqs();
78        $normalizedCategoryNumbers = $this->normalizeCategoryTree($categoryTree, $categoryNumbers);
79        $aggregatedNumbers = $categoryRelation->getAggregatedFaqNumbers($normalizedCategoryNumbers);
80
81        return [$categoryTree, $normalizedCategoryNumbers, $aggregatedNumbers];
82    }
83
84    /**
85     * Returns the category tree as a nested data structure for Twig rendering.
86     *
87     * @return array<int, array<string, mixed>>
88     */
89    public function getCategoryTreeData(int $parentId = 0): array
90    {
91        [$categoryTree, $normalizedCategoryNumbers, $aggregatedNumbers] = $this->gatherCategoryData();
92
93        if ([] === $categoryTree) {
94            return [];
95        }
96
97        return $this->buildCategoryNodes($categoryTree, $parentId, $aggregatedNumbers, $normalizedCategoryNumbers);
98    }
99
100    /**
101     * Recursively builds the nested category node array.
102     *
103     * @param array<int, array<string, mixed>> $categoryTree
104     * @param array<int, int> $aggregatedNumbers
105     * @param array<int, array<string, mixed>> $categoryNumbers
106     * @return array<int, array<string, mixed>>
107     */
108    public function buildCategoryNodes(
109        array $categoryTree,
110        int $parentId = 0,
111        array $aggregatedNumbers = [],
112        array $categoryNumbers = [],
113    ): array {
114        $nodes = [];
115
116        foreach ($categoryTree as $categoryId => $node) {
117            if ((int) $node['parent_id'] !== $parentId) {
118                continue;
119            }
120
121            $faqCount = (int) ($aggregatedNumbers[$node['id']] ?? 0);
122            $hasFaqs = (int) ($categoryNumbers[$categoryId]['faqs'] ?? 0) > 0;
123
124            $description = trim((string) ($node['description'] ?? ''));
125            $imageFile = trim((string) ($node['image'] ?? ''));
126            $image = $imageFile !== ''
127                ? sprintf('%scontent/user/images/%s', $this->configuration->getDefaultUrl(), $imageFile)
128                : null;
129
130            $nodes[] = [
131                'id' => (int) $node['id'],
132                'name' => (string) ($node['name'] ?? ''),
133                'description' => $description === '' ? null : $description,
134                'url' => sprintf(
135                    '%scategory/%d/%s.html',
136                    $this->configuration->getDefaultUrl(),
137                    (int) $node['id'],
138                    TitleSlugifier::slug((string) ($node['name'] ?? '')),
139                ),
140                'image' => $image,
141                'faqCount' => $faqCount,
142                'faqCountLabel' => $this->plurals()->get(key: 'plmsgEntries', number: $faqCount),
143                'hasFaqs' => $hasFaqs,
144                'avatarColor' => sprintf('hsl(%d, 55%%, 45%%)', abs(crc32((string) $node['name'])) % 360),
145                'children' => $this->buildCategoryNodes(
146                    $categoryTree,
147                    (int) $node['id'],
148                    $aggregatedNumbers,
149                    $categoryNumbers,
150                ),
151            ];
152        }
153
154        return $nodes;
155    }
156
157    /**
158     * Builds a category list
159     *
160     * @param array<int, array<string, mixed>> $categoryTree
161     * @param array<int, int> $aggregatedNumbers
162     * @param array<int, array<string, mixed>> $categoryNumbers
163     */
164    public function buildCategoryList(
165        array $categoryTree,
166        int $parentId = 0,
167        array $aggregatedNumbers = [],
168        array $categoryNumbers = [],
169    ): string {
170        $html = '';
171        foreach ($categoryTree as $categoryId => $node) {
172            if ((int) ($node['parent_id'] ?? 0) !== $parentId) {
173                continue;
174            }
175
176            $nodeId = (int) ($node['id'] ?? 0);
177            $number = $aggregatedNumbers[$nodeId] ?? 0;
178
179            $name = (string) ($node['name'] ?? '');
180            if ((int) ($categoryNumbers[$categoryId]['faqs'] ?? 0) > 0) {
181                $url = sprintf(
182                    '%scategory/%d/%s.html',
183                    $this->configuration->getDefaultUrl(),
184                    $nodeId,
185                    TitleSlugifier::slug($name),
186                );
187
188                $link = new Link($url, $this->configuration);
189                $link->setTitle((string) ($node['name'] ?? ''));
190                $link->text = (string) ($node['name'] ?? '');
191                $link->tooltip = is_null($node['description'] ?? null) ? '' : (string) $node['description'];
192                $name = $link->toHtmlAnchor();
193            }
194
195            $description = trim((string) ($node['description'] ?? ''));
196            $descriptionHtml = '' === $description ? '' : sprintf('<br><small>%s</small>', $description);
197
198            $html .= sprintf(
199                '<li data-category-id="%d">%s <span class="badge text-bg-primary">%s</span>%s',
200                $nodeId,
201                $name,
202                $this->plurals()->get(key: 'plmsgEntries', number: $number),
203                $descriptionHtml,
204            );
205            $html .= sprintf('<ul>%s</ul>', $this->buildCategoryList(
206                $categoryTree,
207                $nodeId,
208                $aggregatedNumbers,
209                $categoryNumbers,
210            ));
211            $html .= '</li>';
212        }
213
214        return $html;
215    }
216
217    /**
218     * Returns a list of items with linked translated categories
219     *
220     * @param array<string, string> $availableCategoryTranslations
221     */
222    public function buildAvailableCategoryTranslationsList(array $availableCategoryTranslations): string
223    {
224        $html = '';
225
226        foreach ($availableCategoryTranslations as $language => $category) {
227            $url = sprintf('%sshow-categories.html?lang=%s', $this->configuration->getDefaultUrl(), $language);
228            $link = new Link($url, $this->configuration);
229            $link->setTitle(Strings::htmlentities($category));
230            $link->text = Strings::htmlentities($category);
231            $name = $link->toHtmlAnchor();
232            $html .= sprintf('<li><strong>%s</strong>: %s</li>', LanguageCodes::get($language), $name);
233        }
234
235        return $html;
236    }
237
238    /**
239     * Normalizes the category tree with the number of FAQs per category
240     *
241     * @param array<int, array<string, mixed>> $categoryTree
242     * @param array<int, array<string, mixed>> $categoryNumbers
243     * @return array<int, array<string, mixed>>
244     */
245    public function normalizeCategoryTree(array $categoryTree, array $categoryNumbers): array
246    {
247        $normalizedCategoryTree = [];
248
249        foreach ($categoryTree as $categoryId => $category) {
250            $normalizedCategoryTree[(int) ($category['id'] ?? 0)] = [
251                'category_id' => $categoryId,
252                'parent_id' => (int) ($category['parent_id'] ?? 0),
253                'name' => (string) ($category['name'] ?? ''),
254                'description' => $category['description'] ?? null,
255                'faqs' => (int) ($categoryNumbers[$categoryId]['faqs'] ?? 0),
256            ];
257        }
258
259        return $normalizedCategoryTree;
260    }
261
262    /**
263     * Returns an array with all moderators for the given categories.
264     *
265     * @param int[] $categories
266     * @return string[]
267     * @throws Exception
268     */
269    public function getModerators(array $categories): array
270    {
271        $recipients = [];
272
273        // Ensure we have a valid Category instance before proceeding
274        $categoryInstance = $this->Category;
275        if (!$categoryInstance instanceof Category) {
276            return $recipients;
277        }
278
279        $user = new User($this->configuration);
280
281        // Track already added emails to avoid duplicates
282        $seen = [];
283
284        foreach ($categories as $category) {
285            $userId = $categoryInstance->getOwner((int) $category);
286            $groupId = $categoryInstance->getModeratorGroupId((int) $category);
287
288            $user->getUserById($userId);
289            $emailCategoryOwner = $user->getUserData('email');
290
291            if (
292                is_string($emailCategoryOwner)
293                && $emailCategoryOwner !== ''
294                && !array_key_exists($emailCategoryOwner, $seen)
295            ) {
296                $recipients[] = $emailCategoryOwner;
297                $seen[$emailCategoryOwner] = true;
298            }
299
300            if ($groupId > 0 && $user->perm instanceof MediumPermission) {
301                $moderators = $user->perm->getGroupMembers($groupId);
302                foreach ($moderators as $moderator) {
303                    $user->getUserById($moderator);
304                    $moderatorEmail = $user->getUserData('email');
305                    if (!is_string($moderatorEmail) || $moderatorEmail === '') {
306                        continue;
307                    }
308
309                    if (array_key_exists($moderatorEmail, $seen)) {
310                        continue;
311                    }
312
313                    $recipients[] = $moderatorEmail;
314                    $seen[$moderatorEmail] = true;
315                }
316            }
317        }
318
319        return array_unique($recipients);
320    }
321
322    /**
323     * Renders the <option> tags for the available translations for a given category.
324     */
325    public function renderAvailableTranslationsOptions(int $categoryId): string
326    {
327        $options = '';
328
329        $categoryLanguageService = new CategoryLanguageService();
330        $existingTranslations = $categoryLanguageService->getExistingTranslations($this->configuration, $categoryId);
331
332        foreach ($existingTranslations as $code => $displayName) {
333            $options .= sprintf('<option value="%s">%s</option>', $code, $displayName);
334        }
335
336        return $options;
337    }
338}