Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
80.41% |
119 / 148 |
|
77.78% |
7 / 9 |
CRAP | |
0.00% |
0 / 1 |
| CategoryHelper | |
80.41% |
119 / 148 |
|
77.78% |
7 / 9 |
42.70 | |
0.00% |
0 / 1 |
| 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 | |||
| 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 | |
| 18 | declare(strict_types=1); |
| 19 | |
| 20 | namespace phpMyFAQ\Helper; |
| 21 | |
| 22 | use phpMyFAQ\Category; |
| 23 | use phpMyFAQ\Category\Language\CategoryLanguageService; |
| 24 | use phpMyFAQ\Category\Relation; |
| 25 | use phpMyFAQ\Core\Exception; |
| 26 | use phpMyFAQ\Language\LanguageCodes; |
| 27 | use phpMyFAQ\Link; |
| 28 | use phpMyFAQ\Link\Util\TitleSlugifier; |
| 29 | use phpMyFAQ\Permission\MediumPermission; |
| 30 | use phpMyFAQ\Strings; |
| 31 | use phpMyFAQ\Translation; |
| 32 | use phpMyFAQ\User; |
| 33 | |
| 34 | /** |
| 35 | * Class CategoryHelper |
| 36 | * |
| 37 | * @package phpMyFAQ\Helper |
| 38 | */ |
| 39 | class 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 | } |