Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.05% covered (warning)
81.05%
355 / 438
46.88% covered (danger)
46.88%
15 / 32
CRAP
0.00% covered (danger)
0.00%
0 / 1
Editor
81.05% covered (warning)
81.05%
355 / 438
46.88% covered (danger)
46.88%
15 / 32
207.56
0.00% covered (danger)
0.00%
0 / 1
 edit
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
2
 create
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 save
98.18% covered (success)
98.18%
54 / 55
0.00% covered (danger)
0.00%
0 / 1
8
 store
70.21% covered (warning)
70.21%
33 / 47
0.00% covered (danger)
0.00%
0 / 1
11.14
 delete
96.43% covered (success)
96.43%
27 / 28
0.00% covered (danger)
0.00%
0 / 1
4
 paths
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
20
 createPaths
85.71% covered (warning)
85.71%
18 / 21
0.00% covered (danger)
0.00%
0 / 1
5.07
 complete
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 refuseIncomplete
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 issues
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 applyForm
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 applySettings
57.14% covered (warning)
57.14%
8 / 14
0.00% covered (danger)
0.00%
0 / 1
20.52
 previewPath
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
42
 blueprint
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 submittedUid
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 collection
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 canCreate
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 parentNode
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 blueprintHandles
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 actor
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
4.12
 types
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 queryState
92.59% covered (success)
92.59%
25 / 27
0.00% covered (danger)
0.00%
0 / 1
9.03
 generatedPaths
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 pathSourceFields
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 editorContext
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
1
 nodeMeta
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 displayDate
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
5.25
 userLabel
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
7.48
 intParam
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
9.36
 openParam
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 stringParam
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 navigation
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Controller\Panel;
6
7use Celema\Core\Exception\HttpBadRequest;
8use Celema\Core\Exception\HttpConflict;
9use Celema\Core\Exception\HttpNotFound;
10use Celema\Core\Factory\Factory;
11use Celema\Core\Request;
12use Celema\Core\Response;
13use Celema\Sire\Issue;
14use Celema\Wire\Creator;
15use Cosray\Actor;
16use Cosray\Bootstrap;
17use Cosray\Cms;
18use Cosray\Collection as CmsCollection;
19use Cosray\Collection\Listing;
20use Cosray\Context;
21use Cosray\Exception\RuntimeException;
22use Cosray\Navigation;
23use Cosray\Node\Factory as NodeFactory;
24use Cosray\Node\PathManager;
25use Cosray\Node\RoutePathGenerator;
26use Cosray\Node\Serializer;
27use Cosray\Node\Store;
28use Cosray\Node\Types;
29use Cosray\Node\Wrapper;
30use Cosray\Panel\CollectionQuery;
31use Cosray\Panel\CollectionUrls;
32use Cosray\Panel\FormPatch;
33use Cosray\Panel\System;
34use DateTimeImmutable;
35use IntlDateFormatter;
36use Throwable;
37
38final class Editor extends Panel
39{
40    private const int LIMIT_DEFAULT = 50;
41    private const int LIMIT_MAX = 250;
42
43    public function edit(Context $context, Cms $cms, string $collection, string $node): array
44    {
45        [$name, $obj] = $this->collection($collection);
46        $query = $this->queryState($obj);
47        $result = $cms->node->byUid($node, published: null);
48
49        if (!$result) {
50            throw new HttpNotFound($this->request);
51        }
52
53        $nodeObj = Wrapper::unwrap($result);
54        $serializer = new Serializer(
55            $this->types(),
56            $cms->nodeFactory()->uid(),
57            $context->assets(),
58            $context->paths(),
59        );
60        $data = $serializer->read(
61            $nodeObj,
62            NodeFactory::dataFor($nodeObj),
63            NodeFactory::fieldNamesFor($nodeObj),
64        );
65
66        return $this->editorContext(
67            mode: 'edit',
68            name: $name,
69            collection: $collection,
70            node: $data,
71            query: $query,
72            context: $context,
73            generatedPaths: $this->generatedPaths($context, $nodeObj, $data),
74            pathSourceFields: $this->pathSourceFields($context, $nodeObj),
75            meta: $this->nodeMeta($data),
76        );
77    }
78
79    public function create(Context $context, Cms $cms, string $collection, string $type): array
80    {
81        [$name, $obj] = $this->collection($collection);
82        $query = $this->queryState($obj);
83
84        if (!$this->canCreate($obj, $type, $query->parent)) {
85            throw new HttpNotFound($this->request);
86        }
87
88        [$nodeObj, $data] = $this->blueprint($cms, $context, $type);
89
90        return $this->editorContext(
91            mode: 'create',
92            name: $name,
93            collection: $collection,
94            node: $data,
95            query: $query,
96            context: $context,
97            generatedPaths: $this->generatedPaths($context, $nodeObj, $data),
98            pathSourceFields: $this->pathSourceFields($context, $nodeObj),
99        );
100    }
101
102    public function save(
103        Context $context,
104        Cms $cms,
105        Factory $factory,
106        string $collection,
107        string $node,
108    ): Response|array {
109        [, $obj] = $this->collection($collection);
110        $query = $this->queryState($obj);
111        $result = $cms->node->byUid($node, published: null);
112
113        if (!$result) {
114            throw new HttpNotFound($this->request);
115        }
116
117        $nodeObj = Wrapper::unwrap($result);
118        $serializer = new Serializer(
119            $this->types(),
120            $cms->nodeFactory()->uid(),
121            $context->assets(),
122            $context->paths(),
123        );
124        $data = $serializer->read(
125            $nodeObj,
126            NodeFactory::dataFor($nodeObj),
127            NodeFactory::fieldNamesFor($nodeObj),
128        );
129
130        $form = $this->formData();
131
132        if (!$this->complete($form)) {
133            return $this->refuseIncomplete($data);
134        }
135
136        $data = $this->applyForm($data, $form);
137        $store = new Store(
138            $context->db,
139            new PathManager(),
140            $this->types(),
141            $cms->nodeFactory()->uid(),
142            factory: $cms->nodeFactory(),
143            cms: $cms,
144            context: $context,
145        );
146        $links = new CollectionUrls($this->panelPath(), $collection, $query);
147        $htmx = $this->request->hasHeader('HX-Request');
148
149        try {
150            $store->save($nodeObj, $data, $context->locales(), $this->actor());
151        } catch (HttpBadRequest $e) {
152            if (!$htmx) {
153                // Non-htmx fallback follows the PRG pattern; errors are
154                // reported through the htmx path the panel always uses.
155                return Response::create($factory)->redirect($links->edit($node), 303);
156            }
157
158            $payload = is_array($e->payload()) ? $e->payload() : [];
159
160            return [
161                'saved' => false,
162                'message' => (string) ($payload['message'] ?? __('node:invalid-data')),
163                'errors' => $this->issues($payload),
164                'published' => (bool) ($data['published'] ?? false),
165                'renderable' => (bool) ($data['type']['renderable'] ?? false),
166                'preview' => null,
167            ];
168        }
169
170        if (!$htmx) {
171            return Response::create($factory)->redirect($links->edit($node), 303);
172        }
173
174        return [
175            'saved' => true,
176            'message' => __('editor:saved'),
177            'errors' => [],
178            'published' => (bool) ($data['published'] ?? false),
179            'renderable' => (bool) ($data['type']['renderable'] ?? false),
180            'preview' => ($form['preview'] ?? null) === '1' ? $this->previewPath($cms, $node) : null,
181        ];
182    }
183
184    public function store(
185        Context $context,
186        Cms $cms,
187        Factory $factory,
188        string $collection,
189        string $type,
190    ): Response|array {
191        [, $obj] = $this->collection($collection);
192        $query = $this->queryState($obj);
193
194        if (!$this->canCreate($obj, $type, $query->parent)) {
195            throw new HttpNotFound($this->request);
196        }
197
198        [$nodeObj, $data] = $this->blueprint($cms, $context, $type);
199
200        $form = $this->formData();
201
202        if (!$this->complete($form)) {
203            return $this->refuseIncomplete($data);
204        }
205
206        $patch = new FormPatch($data['fields']);
207        $submitted = $form['content'] ?? [];
208        $data['content'] = $patch->content(
209            $data['content'],
210            is_array($submitted) ? $submitted : [],
211        );
212        $data = $this->applySettings($data, $form);
213
214        $uid = $this->submittedUid($form);
215
216        if ($uid !== null) {
217            // Honour the uid the create form pre-generated so media uploaded
218            // to node/<uid>/ before the first save resolves to the node.
219            $data['uid'] = $uid;
220        }
221
222        if ($query->parent !== null) {
223            $data['parent'] = $query->parent;
224        }
225
226        $store = new Store(
227            $context->db,
228            new PathManager(),
229            $this->types(),
230            $cms->nodeFactory()->uid(),
231            factory: $cms->nodeFactory(),
232            cms: $cms,
233            context: $context,
234        );
235        $links = new CollectionUrls($this->panelPath(), $collection, $query);
236
237        try {
238            $result = $store->create($nodeObj, $data, $context->locales(), $this->actor());
239        } catch (HttpBadRequest $e) {
240            if (!$this->request->hasHeader('HX-Request')) {
241                return Response::create($factory)->redirect($links->create($type), 303);
242            }
243
244            $payload = is_array($e->payload()) ? $e->payload() : [];
245
246            return [
247                'saved' => false,
248                'message' => (string) ($payload['message'] ?? __('node:invalid-data')),
249                'errors' => $this->issues($payload),
250                'published' => (bool) ($data['published'] ?? false),
251                'renderable' => (bool) ($data['type']['renderable'] ?? false),
252                'preview' => null,
253            ];
254        }
255
256        // The redirect swaps the fresh edit form in; htmx follows it.
257        return Response::create($factory)->redirect(
258            $links->edit((string) ($result['uid'] ?? $data['uid'])),
259            303,
260        );
261    }
262
263    public function delete(
264        Context $context,
265        Cms $cms,
266        Factory $factory,
267        string $collection,
268        string $node,
269    ): Response|array {
270        [, $obj] = $this->collection($collection);
271        $query = $this->queryState($obj);
272        $result = $cms->node->byUid($node, published: null);
273
274        if (!$result) {
275            throw new HttpNotFound($this->request);
276        }
277
278        $nodeObj = Wrapper::unwrap($result);
279        $store = new Store(
280            $context->db,
281            new PathManager(),
282            $this->types(),
283            $cms->nodeFactory()->uid(),
284            factory: $cms->nodeFactory(),
285            cms: $cms,
286            context: $context,
287        );
288
289        try {
290            $store->delete($nodeObj, $this->actor());
291        } catch (HttpConflict $e) {
292            // Rendered through the editor-save view: the refusal lands in
293            // the status chip, the form stays as it is.
294            $payload = is_array($e->payload()) ? $e->payload() : [];
295
296            return [
297                'saved' => false,
298                'message' => (string) ($payload['message'] ?? __('node:has-children')),
299                'errors' => [],
300                'published' => (bool) $result->meta->get('published'),
301                'renderable' => (bool) $this->types()->get($nodeObj::class, 'renderable', false),
302                'preview' => null,
303            ];
304        }
305
306        $links = new CollectionUrls($this->panelPath(), $collection, $query);
307
308        return Response::create($factory)->redirect($links->collection(), 303);
309    }
310
311    public function paths(Context $context, Cms $cms, string $collection, string $node): array
312    {
313        [, $obj] = $this->collection($collection);
314        $query = $this->queryState($obj);
315        $result = $cms->node->byUid($node, published: null);
316
317        if (!$result) {
318            throw new HttpNotFound($this->request);
319        }
320
321        $nodeObj = Wrapper::unwrap($result);
322        $links = new CollectionUrls($this->panelPath(), $collection, $query);
323        $pathsUrl = $links->paths($node);
324
325        if (!(bool) $this->types()->get($nodeObj::class, 'routable', false)) {
326            return ['paths' => [], 'submitted' => [], 'pathsUrl' => $pathsUrl];
327        }
328
329        $serializer = new Serializer(
330            $this->types(),
331            $cms->nodeFactory()->uid(),
332            $context->assets(),
333            $context->paths(),
334        );
335        $data = $serializer->read(
336            $nodeObj,
337            NodeFactory::dataFor($nodeObj),
338            NodeFactory::fieldNamesFor($nodeObj),
339        );
340        $form = $this->formData();
341        $data = $this->applyForm($data, $form);
342
343        $generator = new RoutePathGenerator($context->db, $this->types());
344        $submitted = is_array($form['paths'] ?? null) ? $form['paths'] : [];
345
346        return [
347            'paths' => $generator->preview($nodeObj::class, $data, $context->locales()),
348            'submitted' => $submitted,
349            'pathsUrl' => $pathsUrl,
350        ];
351    }
352
353    /** Route-path preview for a not-yet-saved node, built from its blueprint. */
354    public function createPaths(Context $context, Cms $cms, string $collection, string $type): array
355    {
356        [, $obj] = $this->collection($collection);
357        $query = $this->queryState($obj);
358
359        if (!$this->canCreate($obj, $type, $query->parent)) {
360            throw new HttpNotFound($this->request);
361        }
362
363        $links = new CollectionUrls($this->panelPath(), $collection, $query);
364        $pathsUrl = $links->createPaths($type);
365        [$nodeObj, $data] = $this->blueprint($cms, $context, $type);
366
367        if (!(bool) $this->types()->get($nodeObj::class, 'routable', false)) {
368            return ['paths' => [], 'submitted' => [], 'pathsUrl' => $pathsUrl];
369        }
370
371        $form = $this->formData();
372        $data = $this->applyForm($data, $form);
373        $uid = $this->submittedUid($form);
374
375        if ($uid !== null) {
376            $data['uid'] = $uid;
377        }
378
379        $generator = new RoutePathGenerator($context->db, $this->types());
380        $submitted = is_array($form['paths'] ?? null) ? $form['paths'] : [];
381
382        return [
383            'paths' => $generator->preview($nodeObj::class, $data, $context->locales()),
384            'submitted' => $submitted,
385            'pathsUrl' => $pathsUrl,
386        ];
387    }
388
389    /**
390     * The editor form renders a sentinel input as its LAST control; a
391     * submission without it lost its tail â€” typically a form-encoded POST
392     * silently truncated by PHP's max_input_vars, or a mangled JSON body.
393     */
394    private function complete(array $form): bool
395    {
396        return ($form['_complete'] ?? null) === '1';
397    }
398
399    /**
400     * Saving an incomplete submission would silently delete the missing
401     * fields (entries rows are replaced wholesale), so this is a hard
402     * stop, not a validation issue.
403     */
404    private function refuseIncomplete(array $data): array
405    {
406        if (!$this->request->hasHeader('HX-Request')) {
407            throw new HttpBadRequest(
408                $this->request,
409                payload: ['message' => __('editor:incomplete-form')],
410            );
411        }
412
413        return [
414            'saved' => false,
415            'message' => __('editor:incomplete-form'),
416            'errors' => [],
417            'published' => (bool) ($data['published'] ?? false),
418            'renderable' => (bool) ($data['type']['renderable'] ?? false),
419            'preview' => null,
420        ];
421    }
422
423    /**
424     * The issues behind a rejected save, reduced to what the error box
425     * renders: the message plus the data path locating the field â€” the
426     * client resolves the path to the matching form control.
427     *
428     * @param array<string, mixed> $payload
429     * @return list<array{message: string, path: list<string|int>}>
430     */
431    private function issues(array $payload): array
432    {
433        $issues = $payload['errors'] ?? null;
434
435        if (!is_array($issues)) {
436            return [];
437        }
438
439        return array_values(array_map(
440            static fn(Issue $issue): array => [
441                'message' => $issue->message,
442                'path' => $issue->path,
443            ],
444            array_filter($issues, static fn(mixed $issue): bool => $issue instanceof Issue),
445        ));
446    }
447
448    /** Apply the submitted editor form (content patch + settings). */
449    private function applyForm(array $data, array $form): array
450    {
451        $patch = new FormPatch($data['fields']);
452        $submitted = $form['content'] ?? [];
453        $data['content'] = $patch->content(
454            $data['content'],
455            is_array($submitted) ? $submitted : [],
456        );
457
458        return $this->applySettings($data, $form);
459    }
460
461    private function applySettings(array $data, array $form): array
462    {
463        if (array_key_exists('handle', $form)) {
464            $data['handle'] = is_string($form['handle']) ? $form['handle'] : null;
465        }
466
467        if (is_array($form['paths'] ?? null)) {
468            $paths = is_array($data['paths'] ?? null) ? $data['paths'] : [];
469
470            foreach ($form['paths'] as $locale => $path) {
471                if (is_string($locale) && is_string($path)) {
472                    $paths[$locale] = $path;
473                }
474            }
475
476            $data['paths'] = $paths;
477        }
478
479        foreach (['published', 'hidden'] as $flag) {
480            if (array_key_exists($flag, $form)) {
481                $data[$flag] = in_array($form[$flag], ['1', 'on', true], true);
482            }
483        }
484
485        if (($form['publish'] ?? null) === '1') {
486            $data['published'] = true;
487        }
488
489        return $data;
490    }
491
492    /** The public path the preview overlay loads after a save. */
493    private function previewPath(Cms $cms, string $uid): ?string
494    {
495        $result = $cms->node->byUid($uid, published: null);
496
497        if (!$result) {
498            return null;
499        }
500
501        $node = Wrapper::unwrap($result);
502        $paths = NodeFactory::dataFor($node)['paths'] ?? [];
503
504        foreach (is_array($paths) ? $paths : [] as $path) {
505            if (is_string($path) && trim($path) !== '') {
506                return $path;
507            }
508        }
509
510        return null;
511    }
512
513    /**
514     * Build a blueprint node object and its serialized create payload.
515     *
516     * @return array{0: object, 1: array<string, mixed>}
517     */
518    private function blueprint(Cms $cms, Context $context, string $type): array
519    {
520        $class = $this->container
521            ->tag(Bootstrap::NODE_TAG)
522            ->entry($type)
523            ->definition();
524        $factory = $cms->nodeFactory();
525        $node = $factory->blueprint($class, $context, $cms);
526        $serializer = new Serializer($this->types(), $factory->uid());
527        $data = $serializer->blueprint($node, NodeFactory::fieldNamesFor($node), $context->locales());
528
529        return [$node, $data];
530    }
531
532    /**
533     * The uid the create form pre-generated, if it is a well-formed handle;
534     * used only in create mode so uploads and the saved node share one uid.
535     */
536    private function submittedUid(array $form): ?string
537    {
538        $uid = $form['uid'] ?? null;
539
540        if (is_string($uid) && preg_match('/^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$/', $uid)) {
541            return $uid;
542        }
543
544        return null;
545    }
546
547    /** @return array{string, CmsCollection} */
548    private function collection(string $collection): array
549    {
550        try {
551            $ref = $this->navigation()->ref($collection);
552        } catch (RuntimeException $e) {
553            throw new HttpNotFound($this->request, previous: $e);
554        }
555
556        $creator = new Creator($this->container);
557        $obj = $creator->create(
558            $ref->class,
559            predefinedTypes: [Request::class => $this->request],
560        );
561        assert($obj instanceof CmsCollection, 'The editor route must resolve a collection');
562
563        return [$ref->meta->label, $obj];
564    }
565
566    private function canCreate(CmsCollection $collection, string $type, ?string $parent): bool
567    {
568        if ($parent === null) {
569            return in_array($type, $this->blueprintHandles($collection), true);
570        }
571
572        if (!$collection->listMeta->showChildren) {
573            return false;
574        }
575
576        $lister = new Listing($collection, $this->types());
577        $childHandles = array_column(
578            $lister->childBlueprints($this->parentNode($collection, $parent)),
579            'slug',
580        );
581
582        return in_array($type, $childHandles, true);
583    }
584
585    private function parentNode(CmsCollection $collection, string $uid): Wrapper
586    {
587        $node = $collection->cms?->node->byUid($uid, published: null);
588
589        if (!$node) {
590            throw new HttpNotFound($this->request);
591        }
592
593        return $node;
594    }
595
596    /** @return list<string> */
597    private function blueprintHandles(CmsCollection $collection): array
598    {
599        $types = $this->types();
600        $handles = [];
601
602        foreach ($collection->blueprints() as $blueprint) {
603            $handles[] = (string) $types->get($blueprint, 'handle');
604        }
605
606        return $handles;
607    }
608
609    private function actor(): Actor
610    {
611        try {
612            $id = $this->request->get('session')->authenticatedUserId();
613        } catch (Throwable) {
614            $id = null;
615        }
616
617        return $id ? new Actor((int) $id) : Actor::system();
618    }
619
620    private function types(): Types
621    {
622        $types = $this->container->get(Types::class);
623        assert($types instanceof Types, 'The node type service must be available');
624
625        return $types;
626    }
627
628    private function queryState(CmsCollection $collection): CollectionQuery
629    {
630        $offset = $this->intParam('offset', 0, min: 0);
631        $limit = $this->intParam('limit', self::LIMIT_DEFAULT, min: 1, max: self::LIMIT_MAX);
632        $dir = strtolower($this->stringParam('dir'));
633
634        if ($dir !== '' && !in_array($dir, ['asc', 'desc'], true)) {
635            throw new HttpBadRequest($this->request);
636        }
637
638        $parent = $this->stringParam('parent');
639        $parent = $parent === '' ? null : $parent;
640        $view = $this->stringParam('view');
641        $open = $this->openParam('open');
642        $defaultView = $collection->listMeta->showChildren && $parent === null ? 'tree' : 'list';
643
644        if ($view === '') {
645            $view = $defaultView;
646        }
647
648        if (!in_array($view, ['tree', 'list'], true)) {
649            throw new HttpBadRequest($this->request);
650        }
651
652        if (!$collection->listMeta->showChildren) {
653            $open = [];
654        }
655
656        return new CollectionQuery(
657            q: $this->stringParam('q'),
658            sort: $this->stringParam('sort'),
659            dir: $dir,
660            offset: $offset,
661            limit: $limit,
662            parent: $parent,
663            view: $view,
664            open: $open,
665            defaultView: $defaultView,
666        );
667    }
668
669    /**
670     * The route paths the current node would generate, previewed in the
671     * settings pane. Empty for non-routable types and create mode.
672     *
673     * @param array<string, mixed> $data
674     * @return array<string, string>
675     */
676    private function generatedPaths(Context $context, object $node, array $data): array
677    {
678        if (!(bool) $this->types()->get($node::class, 'routable', false)) {
679            return [];
680        }
681
682        return new RoutePathGenerator($context->db, $this->types())
683            ->preview($node::class, $data, $context->locales());
684    }
685
686    /**
687     * The node's own content-field names its route template references, so
688     * the editor marks exactly those inputs to live-refresh the path preview.
689     *
690     * @return list<string>
691     */
692    private function pathSourceFields(Context $context, object $node): array
693    {
694        return new RoutePathGenerator($context->db, $this->types())
695            ->referencedFields($this->types()->get($node::class, 'route'));
696    }
697
698    private function editorContext(
699        string $mode,
700        string $name,
701        string $collection,
702        array $node,
703        CollectionQuery $query,
704        Context $context,
705        array $generatedPaths = [],
706        array $pathSourceFields = [],
707        array $meta = [],
708    ): array {
709        $locales = array_map(
710            static fn($locale) => [
711                'id' => $locale->id,
712                'title' => $locale->title,
713                'fallback' => $locale->fallback,
714            ],
715            iterator_to_array($context->locales(), false),
716        );
717
718        return $this->context([
719            'mode' => $mode,
720            'name' => $name,
721            'slug' => $collection,
722            'node' => $node,
723            'locales' => $locales,
724            'defaultLocale' => $context->locales()->getDefault()->id,
725            'system' => new System($this->config, $context->locales())->payload(),
726            'queryState' => $query,
727            'links' => new CollectionUrls($this->panelPath(), $collection, $query),
728            'generatedPaths' => $generatedPaths,
729            'pathSourceFields' => $pathSourceFields,
730            'meta' => $meta,
731        ]);
732    }
733
734    /**
735     * The inspector's fact rows: creation date in the panel locale and the
736     * last editor's display name. Only an existing node has either.
737     *
738     * @return array{created: ?string, editor: ?string}
739     */
740    private function nodeMeta(array $data): array
741    {
742        return [
743            'created' => $this->displayDate($data['created'] ?? null),
744            'editor' => $this->userLabel($data['editor'] ?? null),
745        ];
746    }
747
748    private function displayDate(mixed $value): ?string
749    {
750        if (!is_string($value) || trim($value) === '') {
751            return null;
752        }
753
754        $timezone = $this->config->app->timezone;
755
756        try {
757            $date = new DateTimeImmutable($value, $timezone);
758        } catch (Throwable) {
759            return null;
760        }
761
762        $formatter = new IntlDateFormatter(
763            $this->localeId(),
764            IntlDateFormatter::MEDIUM,
765            IntlDateFormatter::NONE,
766            $timezone,
767        );
768        $formatted = $formatter->format($date->getTimestamp());
769
770        return $formatted === false ? null : $formatted;
771    }
772
773    private function userLabel(mixed $user): ?string
774    {
775        if (!is_array($user)) {
776            return null;
777        }
778
779        $data = $user['data'] ?? null;
780
781        if (is_string($data)) {
782            $data = json_decode($data, true);
783        }
784
785        $candidates = [
786            is_array($data) ? $data['name'] ?? null : null,
787            $user['username'] ?? null,
788            $user['email'] ?? null,
789        ];
790
791        foreach ($candidates as $candidate) {
792            if (is_string($candidate) && trim($candidate) !== '') {
793                return $candidate;
794            }
795        }
796
797        return null;
798    }
799
800    private function intParam(
801        string $key,
802        int $default,
803        int $min,
804        ?int $max = null,
805    ): int {
806        $value = $this->request->param($key, (string) $default);
807
808        if (is_int($value)) {
809            $int = $value;
810        } elseif (is_string($value) && preg_match('/^-?[0-9]+$/', $value)) {
811            $int = (int) $value;
812        } else {
813            throw new HttpBadRequest($this->request);
814        }
815
816        if ($int < $min) {
817            throw new HttpBadRequest($this->request);
818        }
819
820        if ($max !== null && $int > $max) {
821            throw new HttpBadRequest($this->request);
822        }
823
824        return $int;
825    }
826
827    /** @return list<string> */
828    private function openParam(string $key): array
829    {
830        $value = $this->request->param($key, '');
831
832        if (!is_string($value)) {
833            throw new HttpBadRequest($this->request);
834        }
835
836        $open = [];
837
838        foreach (explode(',', $value) as $uid) {
839            $uid = trim($uid);
840
841            if ($uid !== '' && !in_array($uid, $open, true)) {
842                $open[] = $uid;
843            }
844        }
845
846        return $open;
847    }
848
849    private function stringParam(string $key): string
850    {
851        $value = $this->request->param($key, '');
852
853        if (!is_string($value)) {
854            throw new HttpBadRequest($this->request);
855        }
856
857        return trim($value);
858    }
859
860    private function navigation(): Navigation
861    {
862        $navigation = $this->container->get(Navigation::class);
863        assert($navigation instanceof Navigation, 'The navigation service must be available');
864
865        return $navigation;
866    }
867}