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