Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.17% covered (success)
99.17%
119 / 120
92.31% covered (success)
92.31%
12 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
CategoryController
99.17% covered (success)
99.17%
119 / 120
92.31% covered (success)
92.31%
12 / 13
27
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 show
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 index
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 initializeCategory
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 initializeFaq
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 createCategoryHelper
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 renderSpecificCategory
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
1
 renderAllCategories
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
2
 getFaqRecords
87.50% covered (success)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 getSubCategoryContent
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
8
 prepareSubCategoryHelper
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 buildParentNavigationLink
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 getCategoryImageUrl
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3/**
4 * Category Controller
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 2002-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\Controller\Frontend;
21
22use Exception;
23use phpMyFAQ\Category;
24use phpMyFAQ\Entity\CategoryEntity;
25use phpMyFAQ\Faq;
26use phpMyFAQ\Filter;
27use phpMyFAQ\Helper\CategoryHelper;
28use phpMyFAQ\Language\Plurals;
29use phpMyFAQ\Link;
30use phpMyFAQ\Link\Util\TitleSlugifier;
31use phpMyFAQ\Translation;
32use phpMyFAQ\User\UserSession;
33use Symfony\Component\HttpFoundation\Request;
34use Symfony\Component\HttpFoundation\Response;
35use Symfony\Component\Routing\Attribute\Route;
36
37/* @mago-expect lint:final-controller - extended by a test double that stubs the DB-touching sub-category preparation */
38class CategoryController extends AbstractFrontController
39{
40    private CategoryHelper $categoryHelper;
41
42    public function __construct(
43        private readonly UserSession $userSession,
44        private readonly Category $category,
45        private readonly Faq $faq,
46    ) {
47        parent::__construct();
48    }
49
50    /**
51     * Displays a specific category with its FAQs
52     *
53     * @throws Exception
54     */
55    #[Route(path: '/category/{categoryId}/{slug}.html', name: 'public.category.show', methods: ['GET'])]
56    public function show(Request $request): Response
57    {
58        $this->userSession->setCurrentUser($this->currentUser);
59
60        $categoryId = Filter::filterVar($request->attributes->get('categoryId'), FILTER_VALIDATE_INT, 0);
61        $currentGroups = $this->currentUser->perm->getUserGroups($this->currentUser->getUserId());
62
63        $category = $this->initializeCategory($currentGroups);
64        $faq = $this->initializeFaq($currentGroups);
65        $this->categoryHelper = $this->createCategoryHelper();
66
67        $templateVars = $this->renderSpecificCategory($request, $this->userSession, $categoryId, $category, $faq);
68
69        return $this->render('show.twig', $templateVars);
70    }
71
72    /**
73     * Displays all categories
74     *
75     * @throws Exception
76     */
77    #[Route(path: '/show-categories.html', name: 'public.category.showAll', methods: ['GET'])]
78    public function index(Request $request): Response
79    {
80        $this->userSession->setCurrentUser($this->currentUser);
81
82        $currentGroups = $this->currentUser->perm->getUserGroups($this->currentUser->getUserId());
83
84        $category = $this->initializeCategory($currentGroups);
85        $this->categoryHelper = $this->createCategoryHelper();
86
87        $templateVars = $this->renderAllCategories($request, $this->userSession, $category);
88
89        return $this->render('show.twig', $templateVars);
90    }
91
92    /**
93     * Initializes and configures the Category object
94     *
95     * @param array<int> $currentGroups
96     * @throws Exception
97     */
98    private function initializeCategory(array $currentGroups): Category
99    {
100        $category = $this->category;
101        $category->setUser($this->currentUser->getUserId());
102        $category->setGroups($currentGroups);
103        $category->buildCategoryTree();
104
105        return $category;
106    }
107
108    /**
109     * Initializes and configures the FAQ object
110     *
111     * @param array<int> $currentGroups
112     * @throws Exception
113     */
114    private function initializeFaq(array $currentGroups): Faq
115    {
116        $faq = $this->faq;
117        $faq->setUser($this->currentUser->getUserId());
118        $faq->setGroups($currentGroups);
119
120        return $faq;
121    }
122
123    /**
124     * Creates and configures a CategoryHelper instance
125     */
126    private function createCategoryHelper(): CategoryHelper
127    {
128        $categoryHelper = new CategoryHelper();
129        $categoryHelper->setPlurals(new Plurals());
130
131        return $categoryHelper;
132    }
133
134    /**
135     * Renders a specific category with its FAQs and subcategories
136     *
137     * @return array<string, mixed>
138     * @throws Exception
139     */
140    private function renderSpecificCategory(
141        Request $request,
142        UserSession $userSession,
143        int $selectedCategoryId,
144        Category $category,
145        Faq $faq,
146    ): array {
147        $userSession->userTracking('show_category', $selectedCategoryId);
148
149        $categoryEntity = $category->getCategoryData($selectedCategoryId);
150        $records = $this->getFaqRecords($faq, $selectedCategoryId);
151        $subCategoryContent = $this->getSubCategoryContent($category, $selectedCategoryId, $records);
152        $categoryLevelUp = $this->buildParentNavigationLink($category, $categoryEntity);
153        $categoryImage = $this->getCategoryImageUrl($categoryEntity);
154        $categoryHeader = Translation::getString(key: 'msgEntriesIn') . $categoryEntity->getName();
155
156        return [
157            ...$this->getHeader($request),
158            'title' => sprintf('%s - %s', $categoryHeader, $this->configuration->getTitle()),
159            'metaDescription' => sprintf(
160                Translation::getString(key: 'msgCategoryMetaDesc'),
161                $this->configuration->getTitle(),
162            ),
163            'categoryHeader' => $categoryHeader,
164            'isAllCategories' => false,
165            'breadcrumb' => $category->getPathWithStartPage($selectedCategoryId, '/', true),
166            'categoryFaqsHeader' => $categoryEntity->getName(),
167            'categoryDescription' => $categoryEntity->getDescription() ?? '',
168            'categorySubsHeader' => Translation::get(key: 'msgSubCategories'),
169            'categoryImage' => $categoryImage,
170            'categoryContent' => $records,
171            'subCategoryContent' => $subCategoryContent,
172            'categoryLevelUp' => $categoryLevelUp,
173        ];
174    }
175
176    /**
177     * Renders all categories
178     *
179     * @return array<string, mixed>
180     * @throws Exception
181     */
182    private function renderAllCategories(Request $request, UserSession $userSession, Category $category): array
183    {
184        $userSession->userTracking('show_all_categories', 0);
185
186        $this->categoryHelper->setConfiguration($this->configuration)->setCategory($category);
187
188        $categoryHeader = Translation::getString(key: 'msgShowAllCategories');
189
190        return [
191            ...$this->getHeader($request),
192            'title' => sprintf('%s - %s', $categoryHeader, $this->configuration->getTitle()),
193            'metaDescription' => sprintf(
194                Translation::getString(key: 'msgCategoryMetaDesc'),
195                $this->configuration->getTitle(),
196            ),
197            'categoryHeader' => $categoryHeader,
198            'isAllCategories' => true,
199            'breadcrumb' => $category->getPathWithStartPage(0, '/', true),
200            'categoryFaqsHeader' => Translation::get(key: 'msgShowAllCategories'),
201            'categoryDescription' => Translation::get(key: 'msgCategoryDescription'),
202            'categorySubsHeader' => Translation::get(key: 'msgSubCategories'),
203            'categoryTree' => $categoryTreeData = $this->categoryHelper->getCategoryTreeData(0),
204            'categoryEmptyMessage' => $categoryTreeData === [] ? $this->categoryHelper->renderCategoryTree(0) : '',
205            'msgExpandAll' => Translation::get(key: 'msgExpandAll'),
206            'msgCollapseAll' => Translation::get(key: 'msgCollapseAll'),
207            'subCategoryContent' => Translation::get(key: 'msgSubCategoryContent'),
208            'categoryLevelUp' => '',
209        ];
210    }
211
212    /**
213     * Gets FAQ records for a category
214     */
215    private function getFaqRecords(Faq $faq, int $categoryId): string
216    {
217        $faqListData = $faq->getFaqsDataByCategoryId(
218            $categoryId,
219            (string) $this->configuration->get('records.orderby'),
220            (string) $this->configuration->get('records.sortby'),
221        );
222
223        if ($faqListData['items'] === []) {
224            return '';
225        }
226
227        /* @mago-expect analysis:less-specific-nested-argument-type - the FAQ list shape is produced by the typed data layer */
228        return $this->renderView('category-faq-list.twig', $faqListData);
229    }
230
231    /**
232     * Gets subcategory content if available. When the category has no directly
233     * assigned FAQ entries but has child categories, the rendered category tree
234     * is promoted into $records so visitors land on a useful overview instead of
235     * an empty-state message.
236     */
237    private function getSubCategoryContent(Category $category, int $selectedCategoryId, string &$records): ?string
238    {
239        $childNodes = $category->getChildNodes($selectedCategoryId);
240        $hasChildren = is_countable($childNodes) && $childNodes !== [];
241
242        if ($records !== '' && !$hasChildren) {
243            return null;
244        }
245
246        $this->prepareSubCategoryHelper($selectedCategoryId);
247
248        if ($records === '' && $hasChildren) {
249            $records = $this->categoryHelper->renderCategoryTree($selectedCategoryId);
250
251            return null;
252        }
253
254        if ($records === '') {
255            $records = sprintf(
256                '<div class="mb-5 alert alert-info">%s</div>',
257                Translation::getString(key: 'msgErrorNoRecords'),
258            );
259        }
260
261        return $hasChildren ? $this->categoryHelper->renderCategoryTree($selectedCategoryId) : null;
262    }
263
264    /**
265     * Prepares the category helper with a sub-category scoped to the selected
266     * category id. Extracted from getSubCategoryContent to keep the branching
267     * logic free of direct database side effects.
268     */
269    protected function prepareSubCategoryHelper(int $selectedCategoryId): void
270    {
271        $currentGroups = $this->currentUser->perm->getUserGroups($this->currentUser->getUserId());
272        $subCategory = new Category($this->configuration, $currentGroups, true);
273        $subCategory->setUser($this->currentUser->getUserId());
274        $subCategory->transform($selectedCategoryId);
275        $this->categoryHelper->setConfiguration($this->configuration)->setCategory($subCategory);
276    }
277
278    /**
279     * Builds the parent category navigation link
280     */
281    private function buildParentNavigationLink(Category $category, CategoryEntity $categoryEntity): string
282    {
283        if ($categoryEntity->getId() === 0) {
284            return '';
285        }
286
287        $parentId = $categoryEntity->getParentId();
288        $parentName = $category->getCategoryName($parentId);
289
290        $url = $this->configuration->getDefaultUrl() . 'show-categories.html';
291        if ($parentId !== 0) {
292            $slug = TitleSlugifier::slug($parentName);
293            $url = sprintf('%scategory/%d/%s.html', $this->configuration->getDefaultUrl(), $parentId, $slug);
294        }
295
296        $text = $parentName === '' ? Translation::getString(key: 'msgCategoryUp') : $parentName;
297
298        $link = new Link($url, $this->configuration);
299        $link->setTitle($text);
300        $link->text = $text;
301        $link->tooltip = Translation::getString('msgCategoryUp');
302
303        return sprintf('<i class="bi bi-arrow-90deg-up"></i> %s', $link->toHtmlAnchor());
304    }
305
306    /**
307     * Gets the category image URL if available
308     */
309    private function getCategoryImageUrl(CategoryEntity $categoryEntity): ?string
310    {
311        $image = $categoryEntity->getImage();
312
313        if (is_null($image) || $image === '') {
314            return null;
315        }
316
317        return $this->configuration->getDefaultUrl() . 'content/user/images/' . $image;
318    }
319}