Lines 82.31% 135 / 164
Methods 89.47% 17 / 19
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 renderCategoryTree 100.00% 14 / 14 100.00% 1 / 1 2
 gatherCategoryData 100.00% 7 / 7 100.00% 1 / 1 1
 getCategoryTreeData 75.00% 3 / 4 0.00% 0 / 1 2.06
 buildCategoryNodes 100.00% 34 / 34 100.00% 1 / 1 5
 buildCategoryList 100.00% 36 / 36 100.00% 1 / 1 6
 buildAvailableCategoryTranslationsList 100.00% 9 / 9 100.00% 1 / 1 2
 normalizeCategoryTree 100.00% 10 / 10 100.00% 1 / 1 2
 getModerators 0.00% 0 / 28 0.00% 0 / 1 156
 renderAvailableTranslationsOptions 100.00% 6 / 6 100.00% 1 / 1 2
 [phpMyFAQ\Helper\AbstractHelper] setCategory 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Helper\AbstractHelper] getCategory 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Helper\AbstractHelper] category 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Helper\AbstractHelper] plurals 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Helper\AbstractHelper] setCategoryRelation 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Helper\AbstractHelper] setTags 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Helper\AbstractHelper] setPlurals 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Helper\AbstractHelper] setSessionId 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Helper\AbstractHelper] setConfiguration 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Helper\AbstractHelper] getConfiguration 100.00% 1 / 1 100.00% 1 / 1 1
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}

Inherited from phpMyFAQ\Helper\AbstractHelper

47    public function setCategory(Category $Category): AbstractHelper
48    {
49        $this->Category = $Category;
50        return $this;
51    }
53    public function getCategory(): Category
54    {
55        return $this->category();
56    }
61    protected function category(): Category
62    {
63        return $this->Category ?? throw new \LogicException('setCategory() must be called before use.');
64    }
69    protected function plurals(): Plurals
70    {
71        return $this->plurals ?? throw new \LogicException('setPlurals() must be called before use.');
72    }
74    public function setCategoryRelation(Relation $categoryRelation): AbstractHelper
75    {
76        $this->categoryRelation = $categoryRelation;
77        return $this;
78    }
80    public function setTags(Tags $Tags): AbstractHelper
81    {
82        $this->Tags = $Tags;
83        return $this;
84    }
86    public function setPlurals(Plurals $plurals): AbstractHelper
87    {
88        $this->plurals = $plurals;
89        return $this;
90    }
92    public function setSessionId(int|string $sid): AbstractHelper
93    {
94        $this->sessionId = $sid;
95        return $this;
96    }
98    public function setConfiguration(Configuration $configuration): AbstractHelper
99    {
100        $this->configuration = $configuration;
101        return $this;
102    }
104    public function getConfiguration(): Configuration
105    {
106        return $this->configuration;
107    }