Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.95% covered (success)
94.95%
489 / 515
85.42% covered (warning)
85.42%
41 / 48
CRAP
0.00% covered (danger)
0.00%
0 / 1
Menus
94.95% covered (success)
94.95%
489 / 515
85.42% covered (warning)
85.42%
41 / 48
192.55
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
 context
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 manages
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 index
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 create
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 store
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 update
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
6
 delete
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 menu
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 storeItem
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
6
 updateItem
47.37% covered (danger)
47.37%
9 / 19
0.00% covered (danger)
0.00%
0 / 1
2.58
 moveItem
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
3
 moveTarget
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
8
 indentTarget
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 outdentTarget
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 submittedAnchor
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 siblingIds
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 deleteItem
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 treeContext
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
1
 pane
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
11
 paneContext
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
4
 branch
96.97% covered (success)
96.97%
32 / 33
0.00% covered (danger)
0.00%
0 / 1
5
 itemPayload
83.33% covered (warning)
83.33%
40 / 48
0.00% covered (danger)
0.00%
0 / 1
27.89
 valuesFromBody
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 valuesFromData
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
10
 localeMap
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 titleVariants
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
5.01
 localeList
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 locales
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 assetLabel
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 itemTitle
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 form
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 deleteConfirm
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 validate
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
10
 submitted
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 notice
71.43% covered (warning)
71.43%
10 / 14
0.00% covered (danger)
0.00%
0 / 1
16.94
 undoMove
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
 redirect
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 redirectToMenu
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 menuRows
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 contentLocale
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 descriptionSort
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 storedMap
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
6
 label
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 row
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 itemRowFor
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 base
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Controller\Panel;
6
7use Celema\Container\Container;
8use Celema\Core\Exception\HttpNotFound;
9use Celema\Core\Factory\Factory;
10use Celema\Core\Request;
11use Celema\Core\Response;
12use Celema\Quma\Database;
13use Cosray\Cms;
14use Cosray\Config;
15use Cosray\Context;
16use Cosray\Exception\RuntimeException;
17use Cosray\Field\Field;
18use Cosray\Finder\Menu as FinderMenu;
19use Cosray\Finder\MenuItem;
20use Cosray\Locale;
21use Cosray\Locales;
22use Cosray\Menus as MenuWriter;
23use Cosray\Middleware\Permission;
24use Cosray\Title\Sort;
25use Override;
26
27/**
28 * The menus area: the rail lists the menus, and per menu the item tree
29 * with its editing side pane. Every pane interaction is a URL â€”
30 * selection is `?item=`, creation `?add=` â€” so the whole screen
31 * re-renders as one `main` swap and stays deep-linkable.
32 */
33final class Menus extends Panel
34{
35    protected const string AREA = 'menus';
36
37    private const string HANDLE_PATTERN = '/^[a-z0-9-]{1,32}$/';
38
39    private const string TYPE_PATTERN = '/^[a-z][a-z0-9-]{0,31}$/';
40
41    /** Handles that would shadow a literal route segment. */
42    private const array RESERVED_HANDLES = ['create'];
43
44    /** Mirrors the `ck_menus_max_depth` database constraint. */
45    private const int MAX_DEPTH_LIMIT = 10;
46
47    /** @var ?list<array{menu: string, description: array<string, string>, label: string, maxDepth: ?int, items: int, url: string}> */
48    private ?array $menuRows = null;
49
50    public function __construct(
51        Config $config,
52        Container $container,
53        Request $request,
54        private readonly Database $db,
55        private readonly MenuWriter $menus,
56    ) {
57        parent::__construct($config, $container, $request);
58    }
59
60    /**
61     * Every screen in the area renders the rail, so its menus ride the
62     * shared context. The rail replaces a listing screen: `rail` is what
63     * the base class leaves off outside the content area.
64     *
65     * @param array<string, mixed> $data
66     * @return array<string, mixed>
67     */
68    #[Override]
69    protected function context(array $data = []): array
70    {
71        $menus = $this->menuRows();
72
73        $locales = $this->container->instance(Locales::class);
74
75        return parent::context(array_merge([
76            'menuNav' => $menus,
77            'menuLocales' => $this->locales($locales),
78            'menuDefaultLocale' => $locales->getDefault()->id,
79            'menuCreateUrl' => $this->base() . '/create',
80            'manages' => $this->manages(),
81            'rail' => $menus !== [],
82        ], $data));
83    }
84
85    /**
86     * Whether the user may add, rename, or remove menus. A menu's handle is
87     * what templates fetch it by, so changing the set of menus edits the
88     * site's markup contract, not its content.
89     */
90    private function manages(): bool
91    {
92        return $this->permits('manage-menus');
93    }
94
95    /**
96     * The area's entry point. With menus around, the rail is the listing
97     * and this opens the first one; only an empty project stops here.
98     *
99     * @return array<string, mixed>|Response
100     */
101    #[Permission('edit-menus')]
102    public function index(Factory $factory): array|Response
103    {
104        $menus = $this->menuRows();
105
106        if ($menus !== []) {
107            $notice = $this->request->param('notice', '');
108
109            return $this->redirectToMenu(
110                $factory,
111                $menus[0]['menu'],
112                is_string($notice) && $notice !== '' ? ['notice' => $notice] : [],
113            );
114        }
115
116        return $this->context(['notice' => $this->notice()]);
117    }
118
119    /** @return array<string, mixed> */
120    #[Permission('manage-menus')]
121    public function create(Context $context): array
122    {
123        return $this->form($context, '', [], null, []);
124    }
125
126    /** @return array<string, mixed>|Response */
127    #[Permission('manage-menus')]
128    public function store(Context $context, Factory $factory): array|Response
129    {
130        [$handle, $description, $maxDepth] = $this->submitted($context);
131        $errors = $this->validate($handle, $description, $maxDepth, null);
132
133        if ($errors !== []) {
134            return $this->form($context, $handle, $description, $maxDepth, $errors);
135        }
136
137        $this->menus->create($handle, $description, $maxDepth, $this->actor());
138
139        return $this->redirectToMenu($factory, $handle, ['notice' => 'created']);
140    }
141
142    /** @return array<string, mixed>|Response */
143    #[Permission('edit-menus')]
144    public function update(
145        Cms $cms,
146        Context $context,
147        Factory $factory,
148        string $menu,
149    ): array|Response {
150        $this->row($menu);
151        [$handle, $description, $maxDepth] = $this->submitted($context);
152
153        // The field is disabled without the permission, so nothing legitimate
154        // posts a handle here; ignore whatever does.
155        if (!$this->manages()) {
156            $handle = $menu;
157        }
158
159        $errors = $this->validate($handle, $description, $maxDepth, $menu);
160
161        if ($errors === []) {
162            try {
163                $this->menus->update($menu, $description, $maxDepth, $this->actor());
164            } catch (RuntimeException) {
165                // The tree is already deeper than the limit being set.
166                $errors['maxDepth'] = __('menu:error-max-depth-shallow');
167            }
168        }
169
170        if ($errors !== []) {
171            return $this->treeContext($cms, $context, $menu, null, [
172                'handle' => $handle,
173                'description' => $description,
174                'maxDepth' => $maxDepth,
175                'errors' => $errors,
176            ]);
177        }
178
179        if ($handle !== $menu) {
180            $this->menus->rename($menu, $handle);
181        }
182
183        return $this->redirectToMenu($factory, $handle, ['notice' => 'updated']);
184    }
185
186    #[Permission('manage-menus')]
187    public function delete(Factory $factory, string $menu): Response
188    {
189        $this->row($menu);
190        $this->menus->delete($menu);
191
192        return $this->redirect($factory, 'deleted');
193    }
194
195    /** @return array<string, mixed> */
196    #[Permission('edit-menus')]
197    public function menu(Cms $cms, Context $context, string $menu): array
198    {
199        return $this->treeContext($cms, $context, $menu, $this->pane($cms, $context, $menu));
200    }
201
202    /** @return array<string, mixed>|Response */
203    #[Permission('edit-menus')]
204    public function storeItem(
205        Cms $cms,
206        Context $context,
207        Factory $factory,
208        string $menu,
209    ): array|Response {
210        $this->row($menu);
211        $body = $this->formData();
212        $parent = trim((string) ($body['parent'] ?? ''));
213        $parent = $parent === '' ? null : $parent;
214        $anchor = $this->submittedAnchor($menu, $body);
215
216        if ($parent !== null) {
217            $this->itemRowFor($menu, $parent);
218        }
219
220        $values = $this->valuesFromBody($context, $body);
221        [$data, $errors] = $this->itemPayload($cms, $context, $values);
222
223        if ($errors !== []) {
224            return $this->treeContext($cms, $context, $menu, $this->paneContext(
225                $cms,
226                $context,
227                $menu,
228                'create',
229                null,
230                $parent,
231                $values,
232                $errors,
233                $anchor,
234            ));
235        }
236
237        $item = $this->menus->add(
238            $menu,
239            $data,
240            $parent,
241            hidden: $values['hidden'],
242            actor: $this->actor(),
243        );
244
245        if ($anchor !== null) {
246            // `add()` appended it, so the anchor sits at the same index with and
247            // without the new item â€” `place()` removes it before splicing.
248            $siblings = $this->siblingIds($menu, $parent);
249            $index = (int) array_search($anchor['item'], $siblings, true);
250            $this->menus->place($item, $parent, $anchor['side'] === 'after' ? $index + 1 : $index);
251        }
252
253        return $this->redirectToMenu($factory, $menu, [
254            'item' => $item,
255            'notice' => 'item-created',
256        ]);
257    }
258
259    /** @return array<string, mixed>|Response */
260    #[Permission('edit-menus')]
261    public function updateItem(
262        Cms $cms,
263        Context $context,
264        Factory $factory,
265        string $menu,
266        string $item,
267    ): array|Response {
268        $this->itemRowFor($menu, $item);
269        $values = $this->valuesFromBody($context, $this->formData());
270        [$data, $errors] = $this->itemPayload($cms, $context, $values);
271
272        if ($errors !== []) {
273            return $this->treeContext($cms, $context, $menu, $this->paneContext(
274                $cms,
275                $context,
276                $menu,
277                'edit',
278                $item,
279                null,
280                $values,
281                $errors,
282            ));
283        }
284
285        $this->menus->updateItem($item, $data, $values['hidden'], $this->actor());
286
287        return $this->redirectToMenu($factory, $menu, [
288            'item' => $item,
289            'notice' => 'item-saved',
290        ]);
291    }
292
293    #[Permission('edit-menus')]
294    public function moveItem(Factory $factory, string $menu, string $item): Response
295    {
296        $row = $this->itemRowFor($menu, $item);
297        $from = $row['parent'] === null ? null : (string) $row['parent'];
298        $fromIndex = (int) array_search($item, $this->siblingIds($menu, $from), true);
299
300        try {
301            [$parent, $index] = $this->moveTarget($menu, $item, $row);
302            $this->menus->place($item, $parent, $index);
303        } catch (RuntimeException) {
304            return $this->redirectToMenu($factory, $menu, [
305                'item' => $item,
306                'notice' => 'move-rejected',
307            ]);
308        }
309
310        // Where it came from, so the notice can offer to put it back. The item
311        // has left that group, so splicing it in at the same index restores
312        // exactly the previous order.
313        return $this->redirectToMenu($factory, $menu, [
314            'item' => $item,
315            'notice' => 'item-moved',
316            'undoParent' => (string) $from,
317            'undoIndex' => (string) $fromIndex,
318        ]);
319    }
320
321    /**
322     * The sibling group and index a move asks for: the drag contract states
323     * both outright, a direction derives them from where the item sits now.
324     *
325     * @param array<string, mixed> $row
326     * @return array{0: ?string, 1: int}
327     */
328    private function moveTarget(string $menu, string $item, array $row): array
329    {
330        $body = $this->formData();
331
332        if (isset($body['index'])) {
333            $parent = trim((string) ($body['parent'] ?? ''));
334
335            return [$parent === '' ? null : $parent, max(0, (int) $body['index'])];
336        }
337
338        $parent = $row['parent'] === null ? null : (string) $row['parent'];
339        $siblings = $this->siblingIds($menu, $parent);
340        $index = (int) array_search($item, $siblings, true);
341
342        return match ($body['direction'] ?? '') {
343            'in' => $this->indentTarget($menu, $siblings, $index),
344            'out' => $this->outdentTarget($menu, $parent),
345            'up' => [$parent, max(0, $index - 1)],
346            default => [$parent, $index + 1],
347        };
348    }
349
350    /**
351     * Indenting appends the item to the sibling directly above it, which is
352     * the only reading that keeps the operation reversible by outdenting.
353     *
354     * @param list<string> $siblings
355     * @return array{0: ?string, 1: int}
356     */
357    private function indentTarget(string $menu, array $siblings, int $index): array
358    {
359        if ($index === 0) {
360            throw new RuntimeException('The first item of a group has nothing to indent into');
361        }
362
363        $parent = $siblings[$index - 1] ?? throw new RuntimeException('The item has no sibling to indent into');
364
365        return [$parent, count($this->siblingIds($menu, $parent))];
366    }
367
368    /**
369     * Outdenting lands the item right after its former parent, so the branch
370     * it came from stays above it.
371     *
372     * @return array{0: ?string, 1: int}
373     */
374    private function outdentTarget(string $menu, ?string $parent): array
375    {
376        if ($parent === null) {
377            throw new RuntimeException('A root item cannot be outdented');
378        }
379
380        $grandparent = $this->itemRowFor($menu, $parent)['parent'];
381        $grandparent = $grandparent === null ? null : (string) $grandparent;
382        $siblings = $this->siblingIds($menu, $grandparent);
383
384        return [$grandparent, (int) array_search($parent, $siblings, true) + 1];
385    }
386
387    /**
388     * The anchor a create form was opened against, once its item is known to
389     * belong to this menu.
390     *
391     * @param array<string, mixed> $body
392     * @return ?array{side: string, item: string}
393     */
394    private function submittedAnchor(string $menu, array $body): ?array
395    {
396        foreach (['before', 'after'] as $side) {
397            $item = trim((string) ($body[$side] ?? ''));
398
399            if ($item !== '') {
400                $this->itemRowFor($menu, $item);
401
402                return ['side' => $side, 'item' => $item];
403            }
404        }
405
406        return null;
407    }
408
409    /** @return list<string> */
410    private function siblingIds(string $menu, ?string $parent): array
411    {
412        return array_column(
413            $this->db->menus->siblings(['menu' => $menu, 'parent' => $parent])->all(),
414            'item',
415        );
416    }
417
418    #[Permission('edit-menus')]
419    public function deleteItem(Factory $factory, string $menu, string $item): Response
420    {
421        $this->itemRowFor($menu, $item);
422        $this->menus->remove($item);
423
424        return $this->redirectToMenu($factory, $menu, ['notice' => 'item-deleted']);
425    }
426
427    /**
428     * @param ?array{handle: string, description: array<string, string>, maxDepth: ?int, errors: array<string, string>} $props
429     *   the submitted menu fields when a save came back with errors, the
430     *   stored ones otherwise
431     *
432     * @param ?array<string, mixed> $pane
433     * @return array<string, mixed>
434     */
435    private function treeContext(
436        Cms $cms,
437        Context $context,
438        string $menu,
439        ?array $pane,
440        ?array $props = null,
441    ): array {
442        $row = $this->row($menu);
443
444        return $this->context([
445            'menu' => $menu,
446            'description' => $row['label'],
447            'itemCount' => (int) $row['items'],
448            'props' => [
449                ...(
450                    $props ?? [
451                        'handle' => $menu,
452                        'description' => $row['description'],
453                        'maxDepth' => $row['maxDepth'],
454                        'errors' => [],
455                    ]
456                ),
457                'confirm' => $this->deleteConfirm($menu),
458                'locales' => $this->localeList($context),
459                'defaultLocale' => $context->locales()->getDefault()->id,
460            ],
461            // Unexpanded: the editor shows `children` items as stored,
462            // not what they resolve into. The preview beneath renders the
463            // expanded menu as the frontend would emit it.
464            'tree' => $this->branch(
465                new FinderMenu($context, $menu, expand: false, hidden: true),
466                $cms,
467                $this->localeList($context),
468            ),
469            'preview' => $cms->menu($menu)->html(),
470            'pane' => $pane,
471            'notice' => $this->notice($menu),
472            'undo' => $this->undoMove($menu),
473            'urls' => [
474                'tree' => $this->url($menu),
475                'edit' => $this->url($menu, '/edit'),
476                'delete' => $this->url($menu, '/delete'),
477                'add' => $this->url($menu) . '?add=',
478            ],
479        ]);
480    }
481
482    /**
483     * The pane state the URL asks for: `?item=` edits, `?add=` creates
484     * (empty at the root, a uid below that item), otherwise no pane form.
485     *
486     * @return ?array<string, mixed>
487     */
488    private function pane(Cms $cms, Context $context, string $menu): ?array
489    {
490        $item = $this->request->param('item', null);
491
492        if (is_string($item) && $item !== '') {
493            $row = $this->itemRowFor($menu, $item);
494            $data = json_decode((string) $row['data'], true);
495            $values = $this->valuesFromData(is_array($data) ? $data : []);
496            // `hidden` is a column, not part of the `data` payload.
497            $values['hidden'] = (bool) $row['hidden'];
498
499            return $this->paneContext($cms, $context, $menu, 'edit', $item, null, $values, []);
500        }
501
502        foreach (['before', 'after'] as $side) {
503            $anchor = $this->request->param($side, null);
504
505            if (!is_string($anchor) || $anchor === '') {
506                continue;
507            }
508
509            // A sibling of the anchor: its group, on the named side of it.
510            $row = $this->itemRowFor($menu, $anchor);
511
512            return $this->paneContext(
513                $cms,
514                $context,
515                $menu,
516                'create',
517                null,
518                $row['parent'] === null ? null : (string) $row['parent'],
519                $this->valuesFromData([]),
520                [],
521                ['side' => $side, 'item' => $anchor],
522            );
523        }
524
525        $add = $this->request->param('add', null);
526
527        if (is_string($add)) {
528            $parent = trim($add);
529
530            if ($parent !== '') {
531                $this->itemRowFor($menu, $parent);
532            }
533
534            return $this->paneContext(
535                $cms,
536                $context,
537                $menu,
538                'create',
539                null,
540                $parent === '' ? null : $parent,
541                $this->valuesFromData([]),
542                [],
543            );
544        }
545
546        return null;
547    }
548
549    /**
550     * @param array<string, mixed> $values
551     * @param array<string, string> $errors
552     * @param ?array{side: string, item: string} $anchor
553     * @return array<string, mixed>
554     */
555    private function paneContext(
556        Cms $cms,
557        Context $context,
558        string $menu,
559        string $mode,
560        ?string $item,
561        ?string $parent,
562        array $values,
563        array $errors,
564        ?array $anchor = null,
565    ): array {
566        $values['nodeLabel'] = $values['node'] === ''
567            ? ''
568            : $cms->node->byUid($values['node'], published: null)?->label() ?? $values['node'];
569        $values['assetLabel'] = $this->assetLabel($context, $values['asset']);
570        $values['imageLabel'] = $this->assetLabel($context, $values['image']);
571
572        return [
573            'mode' => $mode,
574            'item' => $item,
575            'action' => $mode === 'create'
576                ? $this->url($menu, '/item/create')
577                : $this->url($menu, '/item/' . rawurlencode((string) $item)),
578            'parent' => $parent,
579            'anchor' => $anchor,
580            'parentTitle' => $parent === null ? null : $this->itemTitle($menu, $parent),
581            'values' => $values,
582            'errors' => $errors,
583            'cancelUrl' => $this->url($menu),
584            'locales' => $this->localeList($context),
585            'defaultLocale' => $context->locales()->getDefault()->id,
586            'searchUrls' => [
587                'node' => $this->panelPath() . '/reference/nodes?limit=8',
588                'asset' => $this->panelPath() . '/media/search?kind=',
589                'image' => $this->panelPath() . '/media/search?kind=image',
590            ],
591        ];
592    }
593
594    /**
595     * Flattens one sibling group of the menu iterator into view rows,
596     * recursing into children.
597     *
598     * @param iterable<MenuItem> $items
599     * @param list<array{id: string, title: string}> $locales
600     *
601     * @return list<array<string, mixed>>
602     */
603    private function branch(iterable $items, Cms $cms, array $locales): array
604    {
605        $rows = [];
606
607        foreach ($items as $entry) {
608            $children = $this->branch($entry->children(), $cms, $locales);
609            $title = $entry->title();
610            $titles = $this->titleVariants($entry->titles(), $title, $locales);
611
612            if ($entry->type() === 'children') {
613                $node = $entry->node();
614                $title = __('menu:children-of', [
615                    'title' => $node === null
616                        ? ''
617                        : $cms->node->byUid($node, published: null)?->label() ?? $node,
618                ]);
619                // Interface text, not content: it reads the same whichever
620                // content language the tree is showing.
621                $titles = [];
622            }
623
624            $rows[] = [
625                'id' => $entry->id(),
626                'type' => $entry->type(),
627                'title' => $title,
628                'titles' => $titles,
629                'hidden' => $entry->hidden(),
630                'href' => $entry->href(),
631                'level' => $entry->level(),
632                'children' => $children,
633                'descendants' =>
634                    count($children)
635                        + (int) array_sum(array_column($children, 'descendants')),
636                // Whether a move in each direction is defined at all; the tree
637                // renders the impossible ones as disabled buttons.
638                'first' => false,
639                'last' => false,
640                'nested' => $entry->level() > 1,
641            ];
642        }
643
644        if ($rows !== []) {
645            $rows[0]['first'] = true;
646            $rows[array_key_last($rows)]['last'] = true;
647        }
648
649        return $rows;
650    }
651
652    /**
653     * Builds the jsonb payload from normalized form values; validation
654     * errors keep the offending key out of the payload.
655     *
656     * @param array<string, mixed> $values
657     * @return array{0: array<string, mixed>, 1: array<string, string>}
658     */
659    private function itemPayload(Cms $cms, Context $context, array $values): array
660    {
661        $errors = [];
662        $type = $values['type'];
663
664        if (preg_match(self::TYPE_PATTERN, $type) !== 1) {
665            return [[], ['type' => __('menu:error-item-type')]];
666        }
667
668        $data = ['type' => $type];
669
670        // A `children` item is pure configuration: the linked node, the
671        // depth, and the order â€” no label of its own.
672        if ($type === 'children') {
673            if (
674                $values['node'] === ''
675                || $cms->node->byUid($values['node'], published: null) === null
676            ) {
677                $errors['node'] = __('menu:error-item-node');
678            } else {
679                $data['node'] = $values['node'];
680            }
681
682            $data['levels'] = min(5, max(1, (int) $values['levels']));
683            $data['order'] = in_array($values['order'], FinderMenu::CHILD_ORDERS, true)
684                ? $values['order']
685                : 'title';
686
687            return [$data, $errors];
688        }
689
690        if ($values['title'] !== []) {
691            $data['title'] = $values['title'];
692        } elseif ($type !== 'node') {
693            $errors['title'] = __('menu:error-item-title');
694        }
695
696        if ($type === 'node') {
697            if (
698                $values['node'] === ''
699                || $cms->node->byUid($values['node'], published: null) === null
700            ) {
701                $errors['node'] = __('menu:error-item-node');
702            } else {
703                $data['node'] = $values['node'];
704            }
705        }
706
707        if ($type === 'url') {
708            if ($values['path'] === []) {
709                $errors['path'] = __('menu:error-item-path');
710            } else {
711                foreach ($values['path'] as $path) {
712                    if (preg_match('#^(/|https?://|mailto:|tel:)#', $path) !== 1) {
713                        $errors['path'] = __('menu:error-item-path-shape');
714
715                        break;
716                    }
717                }
718
719                if (!isset($errors['path'])) {
720                    $data['path'] = $values['path'];
721                }
722            }
723        }
724
725        if ($type === 'asset') {
726            if ($values['asset'] === '' || $context->assets()->get($values['asset']) === null) {
727                $errors['asset'] = __('menu:error-item-asset');
728            } else {
729                $data['asset'] = $values['asset'];
730            }
731        }
732
733        if ($values['target'] && in_array($type, ['node', 'url', 'asset'], true)) {
734            $data['target'] = '_blank';
735        }
736
737        if ($values['class'] !== '') {
738            if (mb_strlen($values['class']) > 64) {
739                $errors['class'] = __('menu:error-item-class');
740            } else {
741                $data['class'] = $values['class'];
742            }
743        }
744
745        if ($values['image'] !== '') {
746            if ($context->assets()->get($values['image']) === null) {
747                $errors['image'] = __('menu:error-item-image');
748            } else {
749                $data['image'] = $values['image'];
750            }
751        }
752
753        return [$data, $errors];
754    }
755
756    /**
757     * @param array<array-key, mixed> $body
758     * @return array<string, mixed>
759     */
760    private function valuesFromBody(Context $context, array $body): array
761    {
762        $type = trim((string) ($body['type'] ?? ''));
763
764        return [
765            'type' => $type === '' ? 'label' : $type,
766            'title' => $this->localeMap($context, $body['title'] ?? null),
767            'node' => trim((string) ($body['node'] ?? '')),
768            'path' => $this->localeMap($context, $body['path'] ?? null),
769            'asset' => trim((string) ($body['asset'] ?? '')),
770            'target' => ($body['target'] ?? '') === '_blank',
771            'class' => trim((string) ($body['class'] ?? '')),
772            'image' => trim((string) ($body['image'] ?? '')),
773            'levels' => (int) ($body['levels'] ?? 1),
774            'order' => trim((string) ($body['order'] ?? '')),
775            'hidden' => ($body['hidden'] ?? '') === '1',
776        ];
777    }
778
779    /**
780     * @param array<array-key, mixed> $data
781     * @return array<string, mixed>
782     */
783    private function valuesFromData(array $data): array
784    {
785        $map = static fn(mixed $value): array => (
786            is_array($value)
787                ? array_filter($value, static fn($entry) => is_string($entry) && $entry !== '')
788                : []
789        );
790        $type = $data['type'] ?? null;
791
792        return [
793            'type' => is_string($type) && $type !== '' ? $type : 'node',
794            'title' => $map($data['title'] ?? null),
795            'node' => is_string($data['node'] ?? null) ? $data['node'] : '',
796            'path' => $map($data['path'] ?? null),
797            'asset' => is_string($data['asset'] ?? null) ? $data['asset'] : '',
798            'target' => ($data['target'] ?? '') === '_blank',
799            'class' => is_string($data['class'] ?? null) ? $data['class'] : '',
800            'image' => is_string($data['image'] ?? null) ? $data['image'] : '',
801            'levels' => max(1, (int) ($data['levels'] ?? 1)),
802            'order' => is_string($data['order'] ?? null) ? $data['order'] : '',
803            'hidden' => false,
804        ];
805    }
806
807    /**
808     * Submitted per-locale values reduced to the configured locales,
809     * empties dropped.
810     *
811     * @return array<string, string>
812     */
813    private function localeMap(Context $context, mixed $values): array
814    {
815        if (!is_array($values)) {
816            return [];
817        }
818
819        $map = [];
820
821        foreach ($context->locales() as $locale) {
822            $value = $values[$locale->id] ?? null;
823
824            if (is_string($value) && trim($value) !== '') {
825                $map[$locale->id] = trim($value);
826            }
827        }
828
829        return $map;
830    }
831
832    /**
833     * One entry per locale for a tree row's title: the stored one where there
834     * is one, otherwise the row's resolved title marked as standing in, which
835     * is what makes a missing translation visible while the row stays
836     * recognizable.
837     *
838     * @param array<string, string> $stored
839     * @param list<array{id: string, title: string}> $locales
840     *
841     * @return list<array{locale: string, title: string, fallback: bool}>
842     */
843    private function titleVariants(array $stored, string $resolved, array $locales): array
844    {
845        if (count($locales) < 2) {
846            return [];
847        }
848
849        $variants = [];
850        $standIn = $resolved !== '' ? $resolved : __('menu:untitled');
851
852        foreach ($locales as $locale) {
853            $own = $stored[$locale['id']] ?? '';
854            $variants[] = [
855                'locale' => $locale['id'],
856                'title' => $own !== '' ? $own : $standIn,
857                'fallback' => $own === '',
858            ];
859        }
860
861        return $variants;
862    }
863
864    /** @return list<array{id: string, title: string}> */
865    private function localeList(Context $context): array
866    {
867        return $this->locales($context->locales());
868    }
869
870    /** @return list<array{id: string, title: string}> */
871    private function locales(Locales $locales): array
872    {
873        return array_map(
874            static fn($locale) => ['id' => $locale->id, 'title' => $locale->title],
875            iterator_to_array($locales, false),
876        );
877    }
878
879    private function assetLabel(Context $context, string $uid): string
880    {
881        if ($uid === '') {
882            return '';
883        }
884
885        return $context->assets()->get($uid)->filename ?? $uid;
886    }
887
888    /** Any stored title of the item, for display hints; the id as fallback. */
889    private function itemTitle(string $menu, string $item): string
890    {
891        $data = json_decode((string) $this->itemRowFor($menu, $item)['data'], true);
892        $titles = is_array($data['title'] ?? null)
893            ? array_filter($data['title'], 'is_string')
894            : [];
895
896        return $titles === [] ? $item : (string) reset($titles);
897    }
898
899    /**
900     * The create screen. Editing a menu happens inline on its tree screen,
901     * so this is the only standalone menu form left.
902     *
903     * @param array<string, string> $description
904     * @param array<string, string> $errors
905     * @return array<string, mixed>
906     */
907    private function form(
908        Context $context,
909        string $handle,
910        array $description,
911        ?int $maxDepth,
912        array $errors,
913    ): array {
914        return $this->context([
915            'action' => $this->base() . '/create',
916            'backUrl' => $this->base(),
917            'handle' => $handle,
918            'description' => $description,
919            'maxDepth' => $maxDepth,
920            'errors' => $errors,
921            'locales' => $this->localeList($context),
922            'defaultLocale' => $context->locales()->getDefault()->id,
923        ]);
924    }
925
926    private function deleteConfirm(string $menu): string
927    {
928        $items = (int) $this->row($menu)['items'];
929
930        if ($items === 0) {
931            return __('menu:confirm-delete-empty', ['menu' => $menu]);
932        }
933
934        return __n('menu:confirm-delete', 'menu:confirm-delete-plural', $items, ['menu' => $menu]);
935    }
936
937    /**
938     * @param array<string, string> $description
939     * @return array<string, string>
940     */
941    private function validate(
942        string $handle,
943        array $description,
944        ?int $maxDepth,
945        ?string $current,
946    ): array {
947        $errors = [];
948
949        if (preg_match(self::HANDLE_PATTERN, $handle) !== 1) {
950            $errors['menu'] = __('menu:error-handle');
951        } elseif (in_array($handle, self::RESERVED_HANDLES, true)) {
952            $errors['menu'] = __('menu:error-handle-reserved');
953        } elseif ($handle !== $current && $this->db->menus->exists(['menu' => $handle])->first()) {
954            $errors['menu'] = __('menu:error-handle-taken');
955        }
956
957        // `localeMap()` has already dropped blanks, so an empty map means
958        // the menu carries no name in any configured locale.
959        $tooLong = array_filter($description, static fn(string $text): bool => mb_strlen($text) > 128);
960
961        if ($description === [] || $tooLong !== []) {
962            $errors['description'] = __('menu:error-description');
963        }
964
965        if ($maxDepth !== null && ($maxDepth < 1 || $maxDepth > self::MAX_DEPTH_LIMIT)) {
966            $errors['maxDepth'] = __('menu:error-max-depth');
967        }
968
969        return $errors;
970    }
971
972    /** @return array{0: string, 1: array<string, string>, 2: ?int} */
973    private function submitted(Context $context): array
974    {
975        $data = $this->formData();
976        $handle = $data['menu'] ?? null;
977        $depth = is_string($data['maxDepth'] ?? null) ? trim($data['maxDepth']) : '';
978
979        return [
980            is_string($handle) ? trim($handle) : '',
981            $this->localeMap($context, $data['description'] ?? null),
982            // An empty field means unlimited; anything unparsable falls to 0,
983            // which `validate()` then rejects rather than silently dropping.
984            $depth === '' ? null : (int) $depth,
985        ];
986    }
987
988    private function notice(?string $menu = null): ?string
989    {
990        $notice = $this->request->param('notice', '');
991        $item = $this->request->param('item', '');
992
993        // The only notice that names its subject; the tree screen has already
994        // validated the item, so the lookup is safe here.
995        if ($notice === 'item-moved' && $menu !== null && is_string($item) && $item !== '') {
996            return __('menu:notice-item-moved', ['title' => $this->itemTitle($menu, $item)]);
997        }
998
999        // Literal ids so the i18n scanner sees every key.
1000        return match ($notice) {
1001            'created' => __('menu:notice-created'),
1002            'updated' => __('menu:notice-updated'),
1003            'deleted' => __('menu:notice-deleted'),
1004            'item-created' => __('menu:notice-item-created'),
1005            'item-saved' => __('menu:notice-item-saved'),
1006            'item-deleted' => __('menu:notice-item-deleted'),
1007            'move-rejected' => __('menu:notice-move-rejected'),
1008            default => null,
1009        };
1010    }
1011
1012    /**
1013     * The move that puts the last one back, when the redirect carried one.
1014     * Both halves must be present: the root group posts an empty parent, so
1015     * the index alone cannot say whether an undo was offered.
1016     *
1017     * @return ?array{action: string, parent: string, index: int}
1018     */
1019    private function undoMove(string $menu): ?array
1020    {
1021        $item = $this->request->param('item', '');
1022        $index = $this->request->param('undoIndex', '');
1023
1024        if (
1025            $this->request->param('notice', '') !== 'item-moved'
1026            || !is_string($item)
1027            || $item === ''
1028            || !is_string($index)
1029            || $index === ''
1030        ) {
1031            return null;
1032        }
1033
1034        return [
1035            'action' => $this->url($menu, '/item/' . rawurlencode($item) . '/move'),
1036            'parent' => (string) $this->request->param('undoParent', ''),
1037            'index' => (int) $index,
1038        ];
1039    }
1040
1041    private function redirect(Factory $factory, string $notice): Response
1042    {
1043        return Response::create($factory)->redirect(
1044            $this->base() . '?notice=' . $notice,
1045            303,
1046        );
1047    }
1048
1049    /** @param array<string, string> $params */
1050    private function redirectToMenu(Factory $factory, string $menu, array $params = []): Response
1051    {
1052        $query = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
1053
1054        return Response::create($factory)->redirect(
1055            $this->url($menu) . ($query === '' ? '' : '?' . $query),
1056            303,
1057        );
1058    }
1059
1060    /**
1061     * The menus as the rail renders them. Cached because the rail rides
1062     * every context in the area and the entry point reads it too. Rows
1063     * carry the description both as the stored map, for the edit form,
1064     * and resolved to `label` for display.
1065     *
1066     * @return list<array{menu: string, description: array<string, string>, label: string, maxDepth: ?int, items: int, url: string}>
1067     */
1068    private function menuRows(): array
1069    {
1070        if ($this->menuRows !== null) {
1071            return $this->menuRows;
1072        }
1073
1074        $rows = [];
1075
1076        foreach ($this->db->menus->list(['order' => $this->descriptionSort()])->all() as $row) {
1077            $menu = (string) $row['menu'];
1078            $description = $this->storedMap($row['description']);
1079
1080            $rows[] = [
1081                'menu' => $menu,
1082                'description' => $description,
1083                'label' => $this->label($description),
1084                'maxDepth' => $row['maxDepth'] === null ? null : (int) $row['maxDepth'],
1085                'items' => (int) $row['items'],
1086                'url' => $this->url($menu),
1087            ];
1088        }
1089
1090        return $this->menuRows = $rows;
1091    }
1092
1093    /** The content locale of the request, `zxx` when there is none. */
1094    private function contentLocale(): string
1095    {
1096        $locale = $this->request->get('locale', null);
1097        $id = $locale instanceof Locale ? $locale->id : Field::NEUTRAL_LOCALE;
1098
1099        return Sort::valid($id) ? $id : Field::NEUTRAL_LOCALE;
1100    }
1101
1102    /** Orders the menu list by its description in the request locale. */
1103    private function descriptionSort(): string
1104    {
1105        return Sort::expression($this->contentLocale(), 'm.description');
1106    }
1107
1108    /**
1109     * A stored jsonb locale map as a string map.
1110     *
1111     * @return array<string, string>
1112     */
1113    private function storedMap(mixed $value): array
1114    {
1115        $decoded = is_string($value) ? json_decode($value, true) : $value;
1116        $map = [];
1117
1118        foreach (is_array($decoded) ? $decoded : [] as $locale => $text) {
1119            if (is_string($locale) && is_string($text)) {
1120                $map[$locale] = $text;
1121            }
1122        }
1123
1124        return $map;
1125    }
1126
1127    /**
1128     * The description for display: the request locale, then the neutral
1129     * key, then any stored variant â€” a menu named in one language only
1130     * must still be identifiable in the rail.
1131     *
1132     * @param array<string, string> $description
1133     */
1134    private function label(array $description): string
1135    {
1136        foreach ([$this->contentLocale(), Field::NEUTRAL_LOCALE] as $locale) {
1137            if (($description[$locale] ?? '') !== '') {
1138                return $description[$locale];
1139            }
1140        }
1141
1142        foreach ($description as $text) {
1143            if ($text !== '') {
1144                return $text;
1145            }
1146        }
1147
1148        return '';
1149    }
1150
1151    /** @return array{menu: string, description: array<string, string>, label: string, maxDepth: ?int, items: int, url: string} */
1152    private function row(string $menu): array
1153    {
1154        foreach ($this->menuRows() as $row) {
1155            if ($row['menu'] === $menu) {
1156                return $row;
1157            }
1158        }
1159
1160        throw new HttpNotFound($this->request);
1161    }
1162
1163    /**
1164     * The item's row, 404 unless it belongs to this menu.
1165     *
1166     * @return array<string, mixed>
1167     */
1168    private function itemRowFor(string $menu, string $item): array
1169    {
1170        $row = $this->db->menus->itemRow(['item' => $item])->first();
1171
1172        if (!$row || $row['menu'] !== $menu) {
1173            throw new HttpNotFound($this->request);
1174        }
1175
1176        return $row;
1177    }
1178
1179    private function base(): string
1180    {
1181        return $this->panelPath() . '/menus';
1182    }
1183
1184    private function url(string $menu, string $suffix = ''): string
1185    {
1186        return $this->base() . '/' . rawurlencode($menu) . $suffix;
1187    }
1188}