Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.00% covered (success)
92.00%
69 / 75
81.82% covered (success)
81.82%
9 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Category
92.00% covered (success)
92.00%
69 / 75
81.82% covered (success)
81.82%
9 / 11
26.35
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
 loadCategories
97.78% covered (success)
97.78%
44 / 45
0.00% covered (danger)
0.00%
0 / 1
9
 buildAdminCategoryTree
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getLanguage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setLanguage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getUser
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setUser
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getGroups
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setGroups
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getOwner
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getLevelOf
54.55% covered (warning)
54.55%
6 / 11
0.00% covered (danger)
0.00%
0 / 1
7.35
1<?php
2
3/**
4 * The main admin category class.
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 2024-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     2024-10-12
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Administration;
21
22use phpMyFAQ\Category\Permission\CategoryPermissionService;
23use phpMyFAQ\Configuration;
24use phpMyFAQ\Database;
25
26/**
27 * Class Category
28 *
29 * All methods in this class are only needed for admin category management.
30 */
31class Category
32{
33    private ?string $language = null;
34
35    /** @var array<int, array<array-key, mixed>> */
36    public array $categories = [];
37
38    /** @var array<int, array<array-key, mixed>> */
39    public array $categoryName = [];
40
41    /** @var array<int, array<int, array<array-key, mixed>>> */
42    private array $children = [];
43
44    private int $user = -1;
45
46    /** @var int[] */
47    private array $groups = [-1];
48
49    /** @var array<int, int> */
50    private array $owner = [];
51
52    /** @var array<int, int> */
53    private array $moderators = [];
54
55    public function __construct(
56        private readonly Configuration $configuration,
57    ) {
58    }
59
60    /**
61     * Returns all categories with ordered category IDs.
62     *
63     * @return array<int, array> Ordered categories
64     */
65    public function loadCategories(): array
66    {
67        $categories = [];
68        $languageCheck = '';
69
70        $language = $this->getLanguage();
71        if ($language !== null && preg_match("/^[a-z\-]{2,}$/", $language)) {
72            $languageCheck .= sprintf("AND fc.lang = '%s'", $this->configuration->getDb()->escape($language));
73        }
74
75        // Centralize permission WHERE clause
76        $categoryPermissionService = new CategoryPermissionService();
77        // In admin, include inactive categories (no active filter)
78        $where =
79            $categoryPermissionService->buildWhereClauseWithInactive($this->groups, $this->user) . ' ' . $languageCheck;
80
81        $prefix = Database::getTablePrefix();
82        $query = "SELECT
83                fc.id AS id,
84                fc.lang AS lang,
85                fc.parent_id AS parent_id,
86                fc.name AS name,
87                fc.description AS description,
88                fc.user_id AS user_id,
89                fc.group_id AS group_id,
90                fc.active AS active,
91                fc.image AS image,
92                fc.show_home AS show_home
93            FROM
94                {$prefix}faqcategories fc
95            LEFT JOIN {$prefix}faqcategory_group fg
96                ON fc.id = fg.category_id
97            LEFT JOIN {$prefix}faqcategory_order fco
98                ON fc.id = fco.category_id
99            LEFT JOIN {$prefix}faqcategory_user fu
100                ON fc.id = fu.category_id
101            {$where}
102            GROUP BY
103                fc.id, fc.lang, fc.parent_id, fc.name, fc.description, fc.user_id, fc.group_id, fc.active, fc.image,
104                fc.show_home, fco.position
105            ORDER BY
106                fco.position, fc.id ASC";
107
108        $result = $this->configuration->getDb()->query($query);
109
110        if ($result !== false) {
111            while (true) {
112                $row = $this->configuration->getDb()->fetchArray($result);
113                // The SQLite and PostgreSQL drivers signal end-of-result with
114                // an empty array instead of null, so both must end the loop.
115                if (!is_array($row) || $row === []) {
116                    break;
117                }
118
119                $this->categoryName[(int) $row['id']] = $row;
120                $this->categories[(int) $row['id']] = $row;
121                $this->children[(int) $row['parent_id']][(int) $row['id']] = &$this->categoryName[(int) $row['id']];
122                $this->owner[(int) $row['id']] = (int) $row['user_id'];
123                $this->moderators[(int) $row['id']] = (int) $row['group_id'];
124
125                $categories[(int) $row['id']] = [
126                    'id' => (int) $row['id'],
127                    'lang' => $row['lang'],
128                    'parent_id' => (int) $row['parent_id'],
129                    'name' => $row['name'],
130                    'description' => $row['description'],
131                    'user_id' => (int) $row['user_id'],
132                    'group_id' => (int) $row['group_id'],
133                    'active' => (int) $row['active'],
134                    'show_home' => (int) $row['show_home'],
135                    'image' => $row['image'],
136                    'level' => $this->getLevelOf((int) $row['id']),
137                ];
138            }
139
140            // Ensure level is set for each entry in categoryName
141            foreach ($this->categoryName as $cid => $row) {
142                if (!array_key_exists('id', $row)) {
143                    continue;
144                }
145
146                $this->categoryName[$cid]['level'] = $this->getLevelOf((int) $row['id']);
147            }
148        }
149
150        return $categories;
151    }
152
153    /**
154     * Creates the category tree for the admin category overview.
155     *
156     * @param array<int, array<array-key, mixed>> $categories
157     * @return array<int, array<array-key, mixed>>
158     */
159    public function buildAdminCategoryTree(array $categories, int $parentId = 0): array
160    {
161        $result = [];
162
163        foreach ($categories as $category) {
164            if ((int) ($category['parent_id'] ?? 0) !== $parentId) {
165                continue;
166            }
167
168            $categoryId = (int) ($category['id'] ?? 0);
169            $this->buildAdminCategoryTree($categories, $categoryId);
170            $result[$categoryId] = [];
171        }
172
173        return $result;
174    }
175
176    public function getLanguage(): ?string
177    {
178        return $this->language;
179    }
180
181    public function setLanguage(?string $language): Category
182    {
183        $this->language = $language;
184
185        return $this;
186    }
187
188    public function getUser(): int
189    {
190        return $this->user;
191    }
192
193    public function setUser(int $user = -1): Category
194    {
195        $this->user = $user;
196        return $this;
197    }
198
199    public function getGroups(): array
200    {
201        return $this->groups;
202    }
203
204    /**
205     * @param int[] $groups
206     */
207    public function setGroups(array $groups): Category
208    {
209        $this->groups = $groups;
210        return $this;
211    }
212
213    /**
214     * Returns the user id of the category owner
215     */
216    public function getOwner(?int $categoryId = null): int
217    {
218        return $categoryId === null ? 1 : $this->owner[$categoryId] ?? 1;
219    }
220
221    /**
222     * Get the level of the item id.
223     *
224     * @param int $categoryId Entity id
225     */
226    private function getLevelOf(int $categoryId): int
227    {
228        $alreadyListed = [$categoryId];
229        $level = 0;
230
231        while (
232            array_key_exists($categoryId, $this->categoryName)
233            && array_key_exists('parent_id', $this->categoryName[$categoryId])
234            && (int) $this->categoryName[$categoryId]['parent_id'] !== 0
235        ) {
236            ++$level;
237            $categoryId = (int) $this->categoryName[$categoryId]['parent_id'];
238            if (in_array($categoryId, $alreadyListed, strict: true)) {
239                break;
240            }
241
242            $alreadyListed[] = $categoryId;
243        }
244
245        return $level;
246    }
247}