Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
54.17% covered (warning)
54.17%
39 / 72
55.56% covered (warning)
55.56%
5 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
CategoryTreeNavigator
54.17% covered (warning)
54.17%
39 / 72
55.56% covered (warning)
55.56%
5 / 9
116.65
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
 transform
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 buildTree
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 transformRecursive
7.14% covered (danger)
7.14%
2 / 28
0.00% covered (danger)
0.00%
0 / 1
34.82
 getSymbol
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 expand
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 collapseAll
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 expandTo
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
6.01
 getLineCategory
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
5.12
1<?php
2
3/**
4 * Category tree navigation service.
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 2025 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     2025-10-20
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Category\Navigation;
21
22use phpMyFAQ\Category\CategoryCache;
23use phpMyFAQ\Category\Tree\TreeBuilder;
24
25/**
26 * Service for category tree navigation operations like expand, collapse, transform.
27 */
28class CategoryTreeNavigator
29{
30    public function __construct(
31        private TreeBuilder $treeBuilder = new TreeBuilder(),
32    ) {
33    }
34
35    /**
36     * Transforms the linear array into a 1D array in tree order with info.
37     *
38     * @return array<array<string, mixed>>
39     */
40    public function transform(CategoryCache $categoryCache, int $categoryId): array
41    {
42        $entries = [];
43        $tree = $this->buildTree($categoryCache, $categoryId);
44        $this->transformRecursive($categoryCache, $tree, indent: 0, entries: $entries);
45        return $entries;
46    }
47
48    /**
49     * Builds tree structure for a category.
50     *
51     * @return array<array-key, mixed>
52     */
53    private function buildTree(CategoryCache $categoryCache, int $categoryId): array
54    {
55        return $this->treeBuilder->buildTree(
56            $categoryCache->getCategoryNames(),
57            $categoryCache->getChildren(),
58            $categoryId,
59        );
60    }
61
62    /**
63     * Recursively transforms tree structure into a flat list.
64     *
65     * @param array<array-key, mixed> $tree
66     * @param array<array<string, mixed>> $entries
67     */
68    private function transformRecursive(CategoryCache $categoryCache, array $tree, int $indent, array &$entries): void
69    {
70        // Skip invalid or empty trees
71        if ($tree === [] || !array_key_exists('id', $tree)) {
72            return;
73        }
74
75        $categoryId = (int) $tree['id'];
76        $parentId = (int) ($tree['parent_id'] ?? 0);
77        $children = $tree['children'] ?? [];
78        $children = is_array($children) ? $children : [];
79        $numChildren = count($children);
80
81        $symbol = $this->getSymbol($categoryCache, $categoryId, $parentId, $numChildren);
82
83        $entry = [
84            'id' => $categoryId,
85            'lang' => $tree['lang'] ?? '',
86            'parent_id' => $parentId,
87            'name' => $tree['name'] ?? '',
88            'description' => $tree['description'] ?? '',
89            'user_id' => $tree['user_id'] ?? 0,
90            'group_id' => $tree['group_id'] ?? -1,
91            'active' => $tree['active'] ?? 0,
92            'show_home' => $tree['show_home'] ?? 0,
93            'image' => $tree['image'] ?? '',
94            'level' => $indent,
95            'symbol' => $symbol,
96            'numChildren' => $numChildren,
97        ];
98
99        $entries[] = $entry;
100
101        foreach ($children as $child) {
102            if (!is_array($child)) {
103                continue;
104            }
105
106            $this->transformRecursive($categoryCache, $child, $indent + 1, $entries);
107        }
108    }
109
110    /**
111     * Gets the symbol for tree rendering.
112     */
113    private function getSymbol(CategoryCache $categoryCache, int $categoryId, int $parentId, int $numChildren): string
114    {
115        if ($numChildren > 0) {
116            return 'plus';
117        }
118
119        $siblings = $categoryCache->getChildren()[$parentId] ?? [];
120        $array = array_keys($siblings);
121        return $categoryId === end($array) ? 'angle' : 'medium';
122    }
123
124    /**
125     * Expands a category node in the tree tab.
126     */
127    public function expand(CategoryCache $categoryCache, int $categoryId): void
128    {
129        $lineIndex = $this->getLineCategory($categoryCache, $categoryId);
130        if ($lineIndex >= 0) {
131            $categoryCache->updateTreeTabEntry($lineIndex, ['symbol' => 'minus']);
132        }
133    }
134
135    /**
136     * Collapses all nodes in the tree tab.
137     */
138    public function collapseAll(CategoryCache $categoryCache): void
139    {
140        $numTreeTab = $categoryCache->countTreeTab();
141        for ($i = 0; $i < $numTreeTab; ++$i) {
142            $entry = $categoryCache->getTreeTabEntry($i);
143            if ($entry !== null && array_key_exists('symbol', $entry) && $entry['symbol'] === 'minus') {
144                $categoryCache->updateTreeTabEntry($i, ['symbol' => 'plus']);
145            }
146        }
147    }
148
149    /**
150     * Expands a tree from root to the given category.
151     */
152    public function expandTo(CategoryCache $categoryCache, int $categoryId): void
153    {
154        $this->collapseAll($categoryCache);
155        $ascendants = $this->treeBuilder->getNodes($categoryCache->getCategoryNames(), $categoryId);
156        $ascendants[] = $categoryId;
157        $numAscendants = count($ascendants);
158
159        for ($i = 0; $i < $numAscendants; ++$i) {
160            $lineIndex = $this->getLineCategory($categoryCache, $ascendants[$i]);
161            if ($lineIndex < 0) {
162                continue;
163            }
164
165            $entry = $categoryCache->getTreeTabEntry($lineIndex);
166            if ($entry !== null && array_key_exists('numChildren', $entry)) {
167                $numChildren = (int) $entry['numChildren'];
168                if ($numChildren > 0) {
169                    $this->expand($categoryCache, $ascendants[$i]);
170                    continue;
171                }
172
173                break;
174            }
175        }
176    }
177
178    /**
179     * Gets the line number where to find the node in the tree tab.
180     */
181    private function getLineCategory(CategoryCache $categoryCache, int $categoryId): int
182    {
183        $num = $categoryCache->countTreeTab();
184        for ($i = 0; $i < $num; ++$i) {
185            $entry = $categoryCache->getTreeTabEntry($i);
186            if ($entry !== null && array_key_exists('id', $entry) && $entry['id'] === $categoryId) {
187                return $i;
188            }
189        }
190
191        return -1;
192    }
193}