Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.94% covered (warning)
86.94%
466 / 536
59.46% covered (warning)
59.46%
22 / 37
CRAP
0.00% covered (danger)
0.00%
0 / 1
Editor
86.94% covered (warning)
86.94%
466 / 536
59.46% covered (warning)
59.46%
22 / 37
213.61
0.00% covered (danger)
0.00%
0 / 1
 edit
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
2
 create
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 save
97.96% covered (success)
97.96%
48 / 49
0.00% covered (danger)
0.00%
0 / 1
10
 discard
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
3.00
 savesAsDraft
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 draftFacts
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 nodeStore
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 store
61.76% covered (warning)
61.76%
21 / 34
0.00% covered (danger)
0.00%
0 / 1
13.53
 delete
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
5
 paths
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
12
 createPaths
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
3.03
 blocks
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 createBlocks
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 blocksPreview
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
6
 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
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 applyForm
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 applySettings
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
11
 previewPath
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 blueprint
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
7
 submittedUid
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 origin
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
8
 embed
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 embedFields
65.00% covered (warning)
65.00%
13 / 20
0.00% covered (danger)
0.00%
0 / 1
10.74
 embedContent
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 types
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 queryState
90.91% covered (success)
90.91%
30 / 33
0.00% covered (danger)
0.00%
0 / 1
13.13
 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
82.76% covered (warning)
82.76%
24 / 29
0.00% covered (danger)
0.00%
0 / 1
4.08
 nodeMeta
100.00% covered (success)
100.00%
5 / 5
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
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
7.99
 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
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\Response;
12use Celema\Sire\Issue;
13use Cosray\Api\PreviewToken;
14use Cosray\Bootstrap;
15use Cosray\Cms;
16use Cosray\Collection\Listing;
17use Cosray\Context;
18use Cosray\Exception\NoSuchField;
19use Cosray\Node\Factory as NodeFactory;
20use Cosray\Node\PathManager;
21use Cosray\Node\ReservedPaths;
22use Cosray\Node\RoutePathGenerator;
23use Cosray\Node\Serializer;
24use Cosray\Node\Store;
25use Cosray\Node\Types;
26use Cosray\Node\Wrapper;
27use Cosray\Panel\CollectionQuery;
28use Cosray\Panel\CollectionUrls;
29use Cosray\Panel\FormPatch;
30use Cosray\Panel\NodeUrls;
31use Cosray\Panel\System;
32use Cosray\Richtext\Normalizer;
33use Cosray\Value\Blocks as BlocksValue;
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    /** @return array<string, mixed> */
44    public function edit(Context $context, Cms $cms, string $node): array
45    {
46        $result = $cms->node->working($node);
47
48        if (!$result) {
49            throw new HttpNotFound($this->request);
50        }
51
52        $nodeObj = Wrapper::unwrap($result);
53        $serializer = new Serializer(
54            $this->types(),
55            $cms->nodeFactory()->uid(),
56            $context->assets(),
57            $context->paths(),
58        );
59        $data = $serializer->read(
60            $nodeObj,
61            NodeFactory::dataFor($nodeObj),
62            NodeFactory::fieldNamesFor($nodeObj),
63        );
64
65        return $this->editorContext(
66            mode: 'edit',
67            node: $this->embedFields($data, $this->origin()['embed']),
68            context: $context,
69            generatedPaths: $this->generatedPaths($context, $nodeObj, $data),
70            pathSourceFields: $this->pathSourceFields($context, $nodeObj),
71            meta: $this->nodeMeta($data),
72        );
73    }
74
75    /** @return array<string, mixed> */
76    public function create(Context $context, Cms $cms, string $type): array
77    {
78        [$nodeObj, $data] = $this->blueprint($cms, $context, $type);
79        $embed = $this->origin()['embed'];
80
81        if ($embed !== null) {
82            $data = $this->embedFields($this->embedContent($data), $embed);
83        }
84
85        return $this->editorContext(
86            mode: 'create',
87            node: $data,
88            context: $context,
89            generatedPaths: $this->generatedPaths($context, $nodeObj, $data),
90            pathSourceFields: $this->pathSourceFields($context, $nodeObj),
91        );
92    }
93
94    /** @return Response|array<string, mixed> */
95    public function save(
96        Context $context,
97        Cms $cms,
98        Factory $factory,
99        string $node,
100    ): Response|array {
101        $result = $cms->node->working($node);
102
103        if (!$result) {
104            throw new HttpNotFound($this->request);
105        }
106
107        $nodeObj = Wrapper::unwrap($result);
108        $serializer = new Serializer(
109            $this->types(),
110            $cms->nodeFactory()->uid(),
111            $context->assets(),
112            $context->paths(),
113        );
114        $data = $serializer->read(
115            $nodeObj,
116            NodeFactory::dataFor($nodeObj),
117            NodeFactory::fieldNamesFor($nodeObj),
118        );
119
120        $form = $this->formData();
121
122        if (!$this->complete($form)) {
123            return $this->refuseIncomplete($data);
124        }
125
126        $data = $this->applyForm($data, $form);
127        $store = $this->nodeStore($context, $cms);
128        $links = $this->origin()['links'];
129        $htmx = $this->request->hasHeader('HX-Request');
130
131        try {
132            if ($this->savesAsDraft($nodeObj, $data, $form)) {
133                $store->draft($nodeObj, $data, $context->locales(), $this->actor());
134            } else {
135                $store->publish($nodeObj, $data, $context->locales(), $this->actor());
136            }
137        } catch (HttpBadRequest $e) {
138            if (!$htmx) {
139                // Non-htmx fallback follows the PRG pattern; errors are
140                // reported through the htmx path the panel always uses.
141                return Response::create($factory)->redirect($links->edit($node), 303);
142            }
143
144            $payload = is_array($e->payload()) ? $e->payload() : [];
145
146            return [
147                'saved' => false,
148                'message' => (string) ($payload['message'] ?? __('node:invalid-data')),
149                'errors' => $this->issues($payload),
150                'published' => (bool) ($data['published'] ?? false),
151                'renderable' => (bool) ($data['type']['renderable'] ?? false),
152                'preview' => null,
153            ];
154        }
155
156        if (!$htmx) {
157            return Response::create($factory)->redirect($links->edit($node), 303);
158        }
159
160        $draft = $this->draftFacts($cms->node->working($node)?->meta->get('draft'));
161
162        return [
163            'saved' => true,
164            'message' => $draft === null ? __('editor:saved') : __('editor:changes-saved'),
165            'errors' => [],
166            'published' => (bool) ($data['published'] ?? false),
167            'renderable' => (bool) ($data['type']['renderable'] ?? false),
168            'preview' => ($form['preview'] ?? null) === '1' ? $this->previewPath($node) : null,
169            'draft' => $draft,
170        ];
171    }
172
173    public function discard(
174        Context $context,
175        Cms $cms,
176        Factory $factory,
177        string $node,
178    ): Response {
179        $result = $cms->node->working($node);
180
181        if (!$result) {
182            throw new HttpNotFound($this->request);
183        }
184
185        $links = $this->origin()['links'];
186        $this->nodeStore($context, $cms)->discard(Wrapper::unwrap($result));
187        $response = Response::create($factory);
188
189        if (!$this->request->hasHeader('HX-Request')) {
190            return $response->redirect($links->edit($node), 303);
191        }
192
193        // A followed redirect would land on the discard form's hx-swap="none"
194        // and render nothing. HX-Location has htmx fetch the editor from the
195        // live row into the main region itself; sourced from the discard
196        // form, so the unsaved-changes guard stands aside for that request,
197        // and without the form's confirmation, which the POST already asked.
198        return $response->header('HX-Location', json_encode([
199            'path' => $links->edit($node),
200            'target' => '#main',
201            'swap' => 'innerHTML show:top',
202            'source' => '#node-editor-discard',
203            'confirm' => false,
204            'replace' => 'true',
205        ], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
206    }
207
208    /**
209     * A published node that stays published takes a plain save as its
210     * working copy. Everything else â€” an unpublished node, the switch
211     * turned off, or an explicit publish â€” writes the live row.
212     *
213     * @param array<string, mixed> $data
214     * @param array<array-key, mixed> $form
215     */
216    private function savesAsDraft(object $node, array $data, array $form): bool
217    {
218        return (
219            (bool) ($data['type']['renderable'] ?? false)
220                && (bool) NodeFactory::meta($node, 'published')
221                && (bool) ($data['published'] ?? false)
222                && ($form['publish'] ?? null) !== '1'
223        );
224    }
225
226    /**
227     * The working copy's facts for the editor: when it diverged, and who
228     * last saved it.
229     *
230     * @return ?array{since: ?string, editor: ?string}
231     */
232    private function draftFacts(mixed $draft): ?array
233    {
234        if (!is_array($draft)) {
235            return null;
236        }
237
238        $editor = $draft['editorName'] ?? null;
239
240        return [
241            'since' => $this->displayDate($draft['created'] ?? null),
242            'editor' => is_string($editor) && trim($editor) !== '' ? $editor : null,
243        ];
244    }
245
246    private function nodeStore(Context $context, Cms $cms): Store
247    {
248        return new Store(
249            $context->db,
250            new PathManager(ReservedPaths::fromConfig($context->config)),
251            $this->types(),
252            $cms->nodeFactory()->uid(),
253            factory: $cms->nodeFactory(),
254            cms: $cms,
255            context: $context,
256        );
257    }
258
259    /** @return Response|array<string, mixed> */
260    public function store(
261        Context $context,
262        Cms $cms,
263        Factory $factory,
264        string $type,
265    ): Response|array {
266        [$nodeObj, $data] = $this->blueprint($cms, $context, $type);
267
268        $form = $this->formData();
269
270        if (!$this->complete($form)) {
271            return $this->refuseIncomplete($data);
272        }
273
274        $patch = new FormPatch($data['fields']);
275        $submitted = $form['content'] ?? [];
276        $data['content'] = $patch->content(
277            $data['content'],
278            is_array($submitted) ? $submitted : [],
279        );
280        $data = $this->applySettings($data, $form);
281
282        $uid = $this->submittedUid($form);
283
284        if ($uid !== null) {
285            // Honour the uid the create form pre-generated so media uploaded
286            // to node/<uid>/ before the first save resolves to the node.
287            $data['uid'] = $uid;
288        }
289
290        $store = $this->nodeStore($context, $cms);
291        $links = $this->origin()['links'];
292
293        try {
294            $result = $store->create($nodeObj, $data, $context->locales(), $this->actor());
295        } catch (HttpBadRequest $e) {
296            if (!$this->request->hasHeader('HX-Request')) {
297                return Response::create($factory)->redirect($links->create($type, $data['parent'] ?? null), 303);
298            }
299
300            $payload = is_array($e->payload()) ? $e->payload() : [];
301
302            return [
303                'saved' => false,
304                'message' => (string) ($payload['message'] ?? __('node:invalid-data')),
305                'errors' => $this->issues($payload),
306                'published' => (bool) ($data['published'] ?? false),
307                'renderable' => (bool) ($data['type']['renderable'] ?? false),
308                'preview' => null,
309            ];
310        }
311
312        $edit = $links->edit((string) ($result['uid'] ?? $data['uid']));
313
314        // The redirect swaps the fresh edit form in; htmx follows it. An
315        // embedded editor tells its frame that the node now exists.
316        return Response::create($factory)->redirect(
317            $this->origin()['embed'] === null ? $edit : $edit . (str_contains($edit, '?') ? '&' : '?') . 'created=1',
318            303,
319        );
320    }
321
322    /** @return Response|array<string, mixed> */
323    public function delete(
324        Context $context,
325        Cms $cms,
326        Factory $factory,
327        string $node,
328    ): Response|array {
329        $origin = $this->origin();
330        $result = $cms->node->working($node);
331
332        if (!$result) {
333            throw new HttpNotFound($this->request);
334        }
335
336        $nodeObj = Wrapper::unwrap($result);
337        $store = $this->nodeStore($context, $cms);
338
339        try {
340            $store->delete($nodeObj, $this->actor());
341        } catch (HttpConflict $e) {
342            // Rendered through the editor-save view: the refusal lands in
343            // the status chip, the form stays as it is.
344            $payload = is_array($e->payload()) ? $e->payload() : [];
345
346            return [
347                'saved' => false,
348                'message' => (string) ($payload['message'] ?? __('node:has-children')),
349                'errors' => [],
350                'published' => (bool) $result->meta->get('published'),
351                'renderable' => (bool) $this->types()->get($nodeObj::class, 'renderable', false),
352                'preview' => null,
353            ];
354        }
355
356        $response = Response::create($factory);
357
358        if (!$this->request->hasHeader('HX-Request')) {
359            return $response->redirect($origin['backUrl'], 303);
360        }
361
362        return $response->header('HX-Location', json_encode([
363            'path' => $origin['backUrl'],
364            'target' => '#frame',
365            'swap' => 'innerHTML show:top',
366            'source' => '#node-editor-delete',
367            'confirm' => false,
368        ], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
369    }
370
371    /** @return array<string, mixed> */
372    public function paths(Context $context, Cms $cms, string $node): array
373    {
374        $result = $cms->node->working($node);
375
376        if (!$result) {
377            throw new HttpNotFound($this->request);
378        }
379
380        $nodeObj = Wrapper::unwrap($result);
381        $pathsUrl = new NodeUrls($this->panelPath())->paths($node);
382
383        if (!(bool) $this->types()->get($nodeObj::class, 'routable', false)) {
384            return ['paths' => [], 'pathsUrl' => $pathsUrl];
385        }
386
387        $serializer = new Serializer(
388            $this->types(),
389            $cms->nodeFactory()->uid(),
390            $context->assets(),
391            $context->paths(),
392        );
393        $data = $serializer->read(
394            $nodeObj,
395            NodeFactory::dataFor($nodeObj),
396            NodeFactory::fieldNamesFor($nodeObj),
397        );
398        $form = $this->formData();
399        $data = $this->applyForm($data, $form);
400
401        $generator = new RoutePathGenerator($context->db, $this->types());
402
403        return [
404            'paths' => $generator->preview($nodeObj::class, $data, $context->locales()),
405            'pathsUrl' => $pathsUrl,
406        ];
407    }
408
409    /**
410     * Route-path preview for a not-yet-saved node, built from its blueprint.
411     *
412     * @return array<string, mixed>
413     */
414    public function createPaths(Context $context, Cms $cms, string $type): array
415    {
416        [$nodeObj, $data] = $this->blueprint($cms, $context, $type);
417        $pathsUrl = new NodeUrls($this->panelPath())->createPaths($type, $data['parent'] ?? null);
418
419        if (!(bool) $this->types()->get($nodeObj::class, 'routable', false)) {
420            return ['paths' => [], 'pathsUrl' => $pathsUrl];
421        }
422
423        $form = $this->formData();
424        $data = $this->applyForm($data, $form);
425        $uid = $this->submittedUid($form);
426
427        if ($uid !== null) {
428            $data['uid'] = $uid;
429        }
430
431        $generator = new RoutePathGenerator($context->db, $this->types());
432
433        return [
434            'paths' => $generator->preview($nodeObj::class, $data, $context->locales()),
435            'pathsUrl' => $pathsUrl,
436        ];
437    }
438
439    /**
440     * The layout preview of a blocks field: the field as the site renders
441     * it, built from the submitted form on top of the working copy. Nothing
442     * is validated or saved â€” readers clamp what they load and an empty
443     * block renders nothing, so half-finished content previews as well.
444     *
445     * @return array<string, mixed>
446     */
447    public function blocks(
448        Context $context,
449        Cms $cms,
450        string $node,
451        string $field,
452    ): array {
453        $result = $cms->node->working($node);
454
455        if (!$result) {
456            throw new HttpNotFound($this->request);
457        }
458
459        $nodeObj = Wrapper::unwrap($result);
460        $serializer = new Serializer(
461            $this->types(),
462            $cms->nodeFactory()->uid(),
463            $context->assets(),
464            $context->paths(),
465        );
466        $data = $serializer->read(
467            $nodeObj,
468            NodeFactory::dataFor($nodeObj),
469            NodeFactory::fieldNamesFor($nodeObj),
470        );
471
472        return $this->blocksPreview($context, $cms, $nodeObj, $data, $field);
473    }
474
475    /**
476     * The layout preview for a not-yet-saved node, built from its blueprint.
477     *
478     * @return array<string, mixed>
479     */
480    public function createBlocks(
481        Context $context,
482        Cms $cms,
483        string $type,
484        string $field,
485    ): array {
486        [$nodeObj, $data] = $this->blueprint($cms, $context, $type);
487
488        return $this->blocksPreview($context, $cms, $nodeObj, $data, $field);
489    }
490
491    /**
492     * @param array<string, mixed> $data
493     * @return array<string, mixed>
494     */
495    private function blocksPreview(
496        Context $context,
497        Cms $cms,
498        object $node,
499        array $data,
500        string $field,
501    ): array {
502        $data = $this->applyForm($data, $this->formData());
503
504        if (is_array($data['content'] ?? null)) {
505            // The renderer reads the canonical richtext document the save
506            // would store, not the envelope as the editor submits it.
507            $data['content'] = new Normalizer()->content($data['content']);
508        }
509
510        $hydrated = $cms->nodeFactory()->create($node::class, $context, $cms, $data);
511
512        try {
513            $value = NodeFactory::fieldFor($hydrated, $field)->value();
514        } catch (NoSuchField) {
515            throw new HttpNotFound($this->request);
516        }
517
518        if (!$value instanceof BlocksValue) {
519            throw new HttpNotFound($this->request);
520        }
521
522        $locales = $context->locales();
523        $requested = $this->request->param('locale', '');
524        $locale = is_string($requested) && $locales->exists($requested)
525            ? $locales->get($requested)
526            : $locales->getDefault();
527
528        return [
529            'html' => $context->withLocale($locale, $value->render(...)),
530            'stylesheet' => (string) file_get_contents(dirname(__DIR__, 3) . '/resources/blocks.css'),
531            'locale' => $locale->id,
532        ];
533    }
534
535    /**
536     * The editor form renders a sentinel input as its LAST control; a
537     * submission without it lost its tail â€” typically a form-encoded POST
538     * silently truncated by PHP's max_input_vars, or a mangled JSON body.
539     *
540     * @param array<array-key, mixed> $form
541     */
542    private function complete(array $form): bool
543    {
544        return ($form['_complete'] ?? null) === '1';
545    }
546
547    /**
548     * Saving an incomplete submission would silently delete the missing
549     * fields (entries rows are replaced wholesale), so this is a hard
550     * stop, not a validation issue.
551     *
552     * @param array<string, mixed> $data
553     * @return array<string, mixed>
554     */
555    private function refuseIncomplete(array $data): array
556    {
557        if (!$this->request->hasHeader('HX-Request')) {
558            throw new HttpBadRequest(
559                $this->request,
560                payload: ['message' => __('editor:incomplete-form')],
561            );
562        }
563
564        return [
565            'saved' => false,
566            'message' => __('editor:incomplete-form'),
567            'errors' => [],
568            'published' => (bool) ($data['published'] ?? false),
569            'renderable' => (bool) ($data['type']['renderable'] ?? false),
570            'preview' => null,
571        ];
572    }
573
574    /**
575     * The issues behind a rejected save, reduced to what the error box
576     * renders: the message plus the data path locating the field â€” the
577     * client resolves the path to the matching form control.
578     *
579     * @param array<string, mixed> $payload
580     * @return list<array{message: string, path: list<string|int>}>
581     */
582    private function issues(array $payload): array
583    {
584        $issues = $payload['errors'] ?? null;
585
586        if (!is_array($issues)) {
587            return [];
588        }
589
590        return array_values(array_map(
591            static fn(Issue $issue): array => [
592                'message' => $issue->message,
593                'path' => $issue->path,
594            ],
595            array_filter($issues, static fn(mixed $issue): bool => $issue instanceof Issue),
596        ));
597    }
598
599    /**
600     * Apply the submitted editor form (content patch + settings).
601     *
602     * @param array<string, mixed> $data
603     * @param array<array-key, mixed> $form
604     * @return array<string, mixed>
605     */
606    private function applyForm(array $data, array $form): array
607    {
608        $patch = new FormPatch($data['fields']);
609        $submitted = $form['content'] ?? [];
610        $data['content'] = $patch->content(
611            $data['content'],
612            is_array($submitted) ? $submitted : [],
613        );
614
615        return $this->applySettings($data, $form);
616    }
617
618    /**
619     * @param array<string, mixed> $data
620     * @param array<array-key, mixed> $form
621     * @return array<string, mixed>
622     */
623    private function applySettings(array $data, array $form): array
624    {
625        if (array_key_exists('handle', $form)) {
626            $data['handle'] = is_string($form['handle']) ? $form['handle'] : null;
627        }
628
629        if (is_array($form['paths'] ?? null)) {
630            $paths = is_array($data['paths'] ?? null) ? $data['paths'] : [];
631
632            foreach ($form['paths'] as $locale => $path) {
633                if (is_string($locale) && is_string($path)) {
634                    $paths[$locale] = $path;
635                }
636            }
637
638            $data['paths'] = $paths;
639        }
640
641        foreach (['published', 'hidden'] as $flag) {
642            if (array_key_exists($flag, $form)) {
643                $data[$flag] = in_array($form[$flag], ['1', 'on', true], true);
644            }
645        }
646
647        if (($form['publish'] ?? null) === '1') {
648            $data['published'] = true;
649        }
650
651        return $data;
652    }
653
654    /** The path the preview overlay loads after a save: the node's working copy, by uid. */
655    /**
656     * The site's own preview, or the headless frontend's when `api.preview`
657     * names one, with a token that lets it read the working copy.
658     */
659    private function previewPath(string $uid): string
660    {
661        $template = $this->config->api->preview;
662        $tokens = PreviewToken::fromConfig($this->config);
663
664        if ($template === null || $tokens === null) {
665            return '/preview/' . rawurlencode($uid);
666        }
667
668        return strtr($template, [
669            '{uid}' => rawurlencode($uid),
670            '{token}' => rawurlencode($tokens->issue($uid)),
671            '{locale}' => rawurlencode($this->localeId()),
672        ]);
673    }
674
675    /**
676     * Build a blueprint node object and its serialized create payload.
677     *
678     * @return array{0: object, 1: array<string, mixed>}
679     */
680    private function blueprint(Cms $cms, Context $context, string $type): array
681    {
682        $registered = $this->container->tag(Bootstrap::NODE_TAG);
683
684        if (!$registered->has($type)) {
685            throw new HttpNotFound($this->request);
686        }
687
688        $class = $registered->entry($type)->definition();
689        $parent = $this->stringParam('parent', $this->request->params());
690
691        if ($parent !== '') {
692            $parentNode = $cms->node->byUid($parent, published: null);
693            $children = $parentNode?->meta->type->get('children', []);
694
695            if (!$parentNode || !is_array($children) || !in_array($class, $children, true)) {
696                throw new HttpNotFound($this->request);
697            }
698        }
699
700        $factory = $cms->nodeFactory();
701        $node = $factory->blueprint($class, $context, $cms);
702        $serializer = new Serializer($this->types(), $factory->uid());
703        $data = $serializer->blueprint($node, NodeFactory::fieldNamesFor($node), $context->locales());
704        $data['parent'] = $parent === '' ? null : $parent;
705
706        return [$node, $data];
707    }
708
709    /**
710     * The uid the create form pre-generated, if it is a well-formed handle;
711     * used only in create mode so uploads and the saved node share one uid.
712     *
713     * @param array<array-key, mixed> $form
714     */
715    private function submittedUid(array $form): ?string
716    {
717        $uid = $form['uid'] ?? null;
718
719        if (is_string($uid) && preg_match('/^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$/', $uid)) {
720            return $uid;
721        }
722
723        return null;
724    }
725
726    /**
727     * @return array{
728     *     backUrl: string,
729     *     backLabel: string,
730     *     activeCollection: ?string,
731     *     links: NodeUrls,
732     *     embed: ?array{origins: list<string>, fields: ?list<string>, lang: ?list<string>, created: bool},
733     * }
734     */
735    private function origin(): array
736    {
737        $origin = [
738            'backUrl' => $this->homeUrl(),
739            'backLabel' => __('nav:home'),
740            'activeCollection' => null,
741            'links' => new NodeUrls($this->panelPath()),
742            'embed' => null,
743        ];
744        $from = $this->request->param('from', '');
745
746        if ($from === 'embed' && $this->config->panel->embed !== []) {
747            $embed = $this->embed();
748
749            return array_replace($origin, [
750                'links' => new NodeUrls($this->panelPath(), 'embed', extra: array_filter([
751                    'fields' => implode(',', $embed['fields'] ?? []),
752                    'lang' => implode(',', $embed['lang'] ?? []),
753                ])),
754                'embed' => $embed,
755            ]);
756        }
757
758        if ($from === 'dashboard' && $this->config->panel->dashboard) {
759            return array_replace($origin, [
760                'backLabel' => __('nav:dashboard'),
761                'links' => new NodeUrls($this->panelPath(), 'dashboard'),
762            ]);
763        }
764
765        if (!is_string($from) || !str_starts_with($from, 'collection:')) {
766            return $origin;
767        }
768
769        $slug = substr($from, strlen('collection:'));
770        $ref = $this->navigation()->refs()[$slug] ?? null;
771
772        if ($ref === null) {
773            return $origin;
774        }
775
776        $query = $this->queryState($this->listing($ref));
777
778        return [
779            'backUrl' => new CollectionUrls($this->panelPath(), $slug, $query)->back(),
780            'backLabel' => __($ref->meta->label),
781            'activeCollection' => $slug,
782            'links' => new NodeUrls($this->panelPath(), $from, $query),
783            'embed' => null,
784        ];
785    }
786
787    /**
788     * The embedded editor's request: the origins that may frame it, the
789     * fields and locales it is limited to, and whether a node was just
790     * created.
791     *
792     * @return array{origins: list<string>, fields: ?list<string>, lang: ?list<string>, created: bool}
793     */
794    private function embed(): array
795    {
796        $list = function (string $name): ?array {
797            $value = $this->request->param($name, '');
798
799            if (!is_string($value) || trim($value) === '') {
800                return null;
801            }
802
803            return array_values(array_filter(
804                array_map(trim(...), explode(',', $value)),
805                static fn(string $item): bool => preg_match('/^[A-Za-z0-9_-]{1,64}$/', $item) === 1,
806            ));
807        };
808
809        return [
810            'origins' => $this->config->panel->embed,
811            'fields' => $list('fields'),
812            'lang' => $list('lang'),
813            'created' => $this->request->param('created', '') === '1',
814        ];
815    }
816
817    /**
818     * Narrows an embedded editor to the fields it asked for. Fields left
819     * out are not submitted, and the save leaves them as they are.
820     *
821     * @param array<string, mixed> $data
822     * @param ?array{fields: ?list<string>} $embed
823     * @return array<string, mixed>
824     */
825    private function embedFields(array $data, ?array $embed): array
826    {
827        $names = $embed['fields'] ?? null;
828
829        if ($names === null) {
830            return $data;
831        }
832
833        $data['fields'] = array_values(array_filter(
834            is_array($data['fields'] ?? null) ? $data['fields'] : [],
835            static fn(mixed $field): bool => is_array($field) && in_array($field['name'] ?? null, $names, true),
836        ));
837        $data['fieldsets'] = array_values(array_filter(array_map(
838            static function (mixed $fieldset) use ($names): ?array {
839                if (!is_array($fieldset)) {
840                    return null;
841                }
842
843                $fieldset['fields'] = array_values(array_intersect(
844                    is_array($fieldset['fields'] ?? null) ? $fieldset['fields'] : [],
845                    $names,
846                ));
847
848                return $fieldset['fields'] === [] ? null : $fieldset;
849            },
850            is_array($data['fieldsets'] ?? null) ? $data['fieldsets'] : [],
851        )));
852
853        return $data;
854    }
855
856    /**
857     * Values an embedding application fills in before the editor opens: a
858     * JSON object of fields in the API's source shape.
859     *
860     * @param array<string, mixed> $data
861     * @return array<string, mixed>
862     */
863    private function embedContent(array $data): array
864    {
865        $given = json_decode((string) $this->request->param('content', ''), true);
866
867        if (!is_array($given)) {
868            return $data;
869        }
870
871        foreach ($given as $name => $value) {
872            if (is_array($value) && is_array($data['content'][$name] ?? null)) {
873                $data['content'][$name] = array_replace_recursive($data['content'][$name], $value);
874            }
875        }
876
877        return $data;
878    }
879
880    private function types(): Types
881    {
882        return $this->container->instance(Types::class);
883    }
884
885    private function queryState(Listing $lister): CollectionQuery
886    {
887        $params = $this->request->param('list', []);
888
889        if (!is_array($params)) {
890            throw new HttpBadRequest($this->request);
891        }
892
893        $offset = $this->intParam('offset', $params, 0, min: 0);
894        $limit = $this->intParam('limit', $params, self::LIMIT_DEFAULT, min: 1, max: self::LIMIT_MAX);
895        $dir = strtolower($this->stringParam('dir', $params));
896
897        if ($dir !== '' && !in_array($dir, ['asc', 'desc'], true)) {
898            throw new HttpBadRequest($this->request);
899        }
900
901        $parent = $this->stringParam('parent', $params);
902        $parent = $lister->meta->showChildren && $parent !== '' ? $parent : null;
903        $view = $this->stringParam('view', $params);
904        $open = $this->openParam($params);
905        $sort = $this->stringParam('sort', $params);
906
907        if ($sort !== '' && !array_key_exists($sort, $lister->sorts)) {
908            throw new HttpBadRequest($this->request);
909        }
910        $defaultView = $lister->meta->showChildren && $parent === null ? 'tree' : 'list';
911
912        if ($view === '') {
913            $view = $defaultView;
914        }
915
916        if (!in_array($view, ['tree', 'list'], true)) {
917            throw new HttpBadRequest($this->request);
918        }
919
920        if (!$lister->meta->showChildren) {
921            $open = [];
922        }
923
924        return new CollectionQuery(
925            q: $this->stringParam('q', $params),
926            sort: $sort,
927            dir: $dir,
928            offset: $offset,
929            limit: $limit,
930            parent: $parent,
931            view: $view,
932            open: $open,
933            defaultView: $defaultView,
934        );
935    }
936
937    /**
938     * The route paths the current node would generate, previewed in the
939     * settings pane. Empty for non-routable types and create mode.
940     *
941     * @param array<string, mixed> $data
942     * @return array<string, string>
943     */
944    private function generatedPaths(Context $context, object $node, array $data): array
945    {
946        if (!(bool) $this->types()->get($node::class, 'routable', false)) {
947            return [];
948        }
949
950        return new RoutePathGenerator($context->db, $this->types())
951            ->preview($node::class, $data, $context->locales());
952    }
953
954    /**
955     * The node's own content-field names its route template references, so
956     * the editor marks exactly those inputs to live-refresh the path preview.
957     *
958     * @return list<string>
959     */
960    private function pathSourceFields(Context $context, object $node): array
961    {
962        return new RoutePathGenerator($context->db, $this->types())
963            ->referencedFields($this->types()->get($node::class, 'route'));
964    }
965
966    /**
967     * @param array<string, mixed> $node
968     * @param array<string, string> $generatedPaths
969     * @param list<string> $pathSourceFields
970     * @param array<string, mixed> $meta
971     * @return array<string, mixed>
972     */
973    private function editorContext(
974        string $mode,
975        array $node,
976        Context $context,
977        array $generatedPaths = [],
978        array $pathSourceFields = [],
979        array $meta = [],
980    ): array {
981        $locales = array_map(
982            static fn($locale) => [
983                'id' => $locale->id,
984                'title' => $locale->title,
985                'fallback' => $locale->fallback,
986            ],
987            iterator_to_array($context->locales(), false),
988        );
989        $languages = $this->origin()['embed']['lang'] ?? null;
990
991        if ($languages !== null) {
992            $narrowed = array_values(array_filter(
993                $locales,
994                static fn(array $locale): bool => in_array($locale['id'], $languages, true),
995            ));
996            $locales = $narrowed === [] ? $locales : $narrowed;
997        }
998
999        return $this->context([
1000            ...$this->origin(),
1001            'mode' => $mode,
1002            'node' => $node,
1003            'locales' => $locales,
1004            'defaultLocale' => in_array($context->locales()->getDefault()->id, array_column($locales, 'id'), true)
1005                ? $context->locales()->getDefault()->id
1006                : $locales[0]['id'],
1007            'system' => new System($this->config, $context->locales(), $this->csrfToken())->payload(),
1008            'generatedPaths' => $generatedPaths,
1009            'pathSourceFields' => $pathSourceFields,
1010            'meta' => $meta,
1011            'inspectorCollapsed' => $this->request->cookie('cosray_inspector', '') === 'collapsed',
1012        ]);
1013    }
1014
1015    /**
1016     * The inspector's fact rows: creation date in the panel locale and the
1017     * last editor's display name. Only an existing node has either.
1018     *
1019     * @param array<string, mixed> $data
1020     * @return array{created: ?string, editor: ?string, draft: ?array{since: ?string, editor: ?string}}
1021     */
1022    private function nodeMeta(array $data): array
1023    {
1024        return [
1025            'created' => $this->displayDate($data['created'] ?? null),
1026            'editor' => $this->userLabel($data['editor'] ?? null),
1027            'draft' => $this->draftFacts($data['draft'] ?? null),
1028        ];
1029    }
1030
1031    private function displayDate(mixed $value): ?string
1032    {
1033        if (!is_string($value) || trim($value) === '') {
1034            return null;
1035        }
1036
1037        $timezone = $this->config->app->timezone;
1038
1039        try {
1040            $date = new DateTimeImmutable($value, $timezone);
1041        } catch (Throwable) {
1042            return null;
1043        }
1044
1045        $formatter = new IntlDateFormatter(
1046            $this->localeId(),
1047            IntlDateFormatter::MEDIUM,
1048            IntlDateFormatter::NONE,
1049            $timezone,
1050        );
1051        $formatted = $formatter->format($date->getTimestamp());
1052
1053        return $formatted === false ? null : $formatted;
1054    }
1055
1056    private function userLabel(mixed $user): ?string
1057    {
1058        if (!is_array($user)) {
1059            return null;
1060        }
1061
1062        $data = $user['data'] ?? null;
1063
1064        if (is_string($data)) {
1065            $data = json_decode($data, true);
1066        }
1067
1068        $candidates = [
1069            is_array($data) ? $data['name'] ?? null : null,
1070            $user['username'] ?? null,
1071            $user['email'] ?? null,
1072        ];
1073
1074        foreach ($candidates as $candidate) {
1075            if (is_string($candidate) && trim($candidate) !== '') {
1076                return $candidate;
1077            }
1078        }
1079
1080        return null;
1081    }
1082
1083    /** @param array<array-key, mixed> $params */
1084    private function intParam(
1085        string $key,
1086        array $params,
1087        int $default,
1088        int $min,
1089        ?int $max = null,
1090    ): int {
1091        $value = $params[$key] ?? $default;
1092
1093        if (is_int($value)) {
1094            $int = $value;
1095        } elseif (is_string($value) && preg_match('/^-?[0-9]+$/', $value)) {
1096            $int = (int) $value;
1097        } else {
1098            throw new HttpBadRequest($this->request);
1099        }
1100
1101        if ($int < $min) {
1102            throw new HttpBadRequest($this->request);
1103        }
1104
1105        if ($max !== null && $int > $max) {
1106            throw new HttpBadRequest($this->request);
1107        }
1108
1109        return $int;
1110    }
1111
1112    /**
1113     * @param array<array-key, mixed> $params
1114     * @return list<string>
1115     */
1116    private function openParam(array $params): array
1117    {
1118        $value = $params['open'] ?? '';
1119
1120        if (!is_string($value)) {
1121            throw new HttpBadRequest($this->request);
1122        }
1123
1124        $open = [];
1125
1126        foreach (explode(',', $value) as $uid) {
1127            $uid = trim($uid);
1128
1129            if ($uid !== '' && !in_array($uid, $open, true)) {
1130                $open[] = $uid;
1131            }
1132        }
1133
1134        return $open;
1135    }
1136
1137    /** @param array<array-key, mixed> $params */
1138    private function stringParam(string $key, array $params): string
1139    {
1140        $value = $params[$key] ?? '';
1141
1142        if (!is_string($value)) {
1143            throw new HttpBadRequest($this->request);
1144        }
1145
1146        return trim($value);
1147    }
1148}