Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.19% covered (success)
99.19%
245 / 247
85.71% covered (warning)
85.71%
12 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
FormPatch
99.19% covered (success)
99.19%
245 / 247
85.71% covered (warning)
85.71%
12 / 14
114
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
 content
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
 entry
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
17
 meta
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 cast
97.50% covered (success)
97.50%
39 / 40
0.00% covered (danger)
0.00%
0 / 1
27
 datetime
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 timezone
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 entries
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 blocks
100.00% covered (success)
100.00%
63 / 63
100.00% covered (success)
100.00%
1 / 1
11
 area
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 layout
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 withMeta
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 rows
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
1 / 1
27
 rowTypes
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Panel;
6
7use Closure;
8use Cosray\Block\Layout;
9use Cosray\Block\Placement;
10use Cosray\DateTime\Codec;
11use Cosray\Field\Blocks;
12use Cosray\Field\Field;
13use Cosray\Uid;
14use DateTimeZone;
15
16/**
17 * Patches stored node content with submitted editor form data.
18 *
19 * The form is a per-field patch, never a reconstruction: only fields the
20 * form actually carries are replaced, unknown keys inside the stored
21 * content survive untouched. Primitive leaves are cast according to the
22 * field's control descriptor; rich fields submit their complete value
23 * (and optionally meta) as one JSON string under the [json] key.
24 *
25 * An immutable field is never patched. The form renders it read-only, so
26 * whatever the submission carries for it has no authority.
27 */
28final class FormPatch
29{
30    /** @param list<array<string, mixed>> $fields field property payloads incl. control descriptors */
31    public function __construct(
32        private readonly array $fields,
33        private readonly Uid $uid = new Uid(Uid::ALPHABET_LOWERCASE_WORD_SAFE, 13),
34    ) {}
35
36    /**
37     * @param array<string, mixed> $stored
38     * @param array<array-key, mixed> $submitted
39     * @return array<string, mixed>
40     */
41    public function content(array $stored, array $submitted): array
42    {
43        foreach ($this->fields as $field) {
44            $name = $field['name'] ?? null;
45
46            if (!is_string($name) || !is_array($submitted[$name] ?? null)) {
47                continue;
48            }
49
50            if ($field['immutable'] ?? false) {
51                continue;
52            }
53
54            $entry = $stored[$name] ?? ['type' => $field['type'] ?? null, 'value' => []];
55            $patched = $this->entry(
56                $field['control'] ?? [],
57                $field['metaControl'] ?? null,
58                $entry,
59                $submitted[$name],
60            );
61
62            if ($patched !== null) {
63                $stored[$name] = $patched;
64            }
65        }
66
67        return $stored;
68    }
69
70    /**
71     * @param array<string, mixed> $control
72     * @param ?array<string, mixed> $metaControl
73     * @param array<array-key, mixed> $entry
74     * @param array<array-key, mixed> $submitted
75     * @return ?array<array-key, mixed>
76     */
77    private function entry(array $control, ?array $metaControl, array $entry, array $submitted): ?array
78    {
79        $json = $submitted['json'] ?? null;
80
81        if (is_string($json)) {
82            $decoded = json_decode($json, true);
83
84            if (!is_array($decoded)) {
85                return null;
86            }
87
88            if (array_key_exists('value', $decoded)) {
89                $entry['value'] = $decoded['value'];
90            }
91
92            if (array_key_exists('meta', $decoded)) {
93                $entry['meta'] = $decoded['meta'];
94            }
95
96            // Format envelope of structured richtext values.
97            foreach (['format', 'version'] as $key) {
98                if (isset($decoded[$key])) {
99                    $entry[$key] = $decoded[$key];
100                }
101            }
102
103            return $entry;
104        }
105
106        $changed = false;
107        $value = $submitted['value'] ?? null;
108
109        // A blocks value keeps the grid it was placed on; its rows are read
110        // against that count, not the field's default.
111        if (($control['name'] ?? null) === 'blocks') {
112            $default = is_int($control['props']['columns'] ?? null) ? $control['props']['columns'] : 1;
113            $columns = Blocks::storedColumns(
114                is_numeric($submitted['columns'] ?? null) ? (int) $submitted['columns'] : null,
115                Blocks::storedColumns($entry['columns'] ?? null, $default),
116            );
117            $control['props']['columns'] = $columns;
118            $entry['columns'] = $columns;
119        }
120
121        if (is_array($value)) {
122            $stored = is_array($entry['value'] ?? null) ? $entry['value'] : [];
123            $timezone = $this->timezone($entry);
124
125            foreach ($value as $locale => $raw) {
126                $stored[$locale] = $this->cast($control, $raw, $stored[$locale] ?? null, $timezone);
127            }
128
129            $entry['value'] = $stored;
130            $changed = true;
131        }
132
133        $meta = $submitted['meta'] ?? null;
134
135        if (is_array($meta) && is_array($metaControl)) {
136            $entry['meta'] = $this->meta(
137                $metaControl,
138                is_array($entry['meta'] ?? null) ? $entry['meta'] : [],
139                $meta,
140            );
141            $changed = true;
142        }
143
144        return $changed ? $entry : null;
145    }
146
147    /**
148     * Replace the meta entries the metaControl group knows; unknown
149     * stored meta keys survive.
150     *
151     * @param array<string, mixed> $metaControl
152     * @param array<array-key, mixed> $stored
153     * @param array<array-key, mixed> $submitted
154     * @return array<array-key, mixed>
155     */
156    private function meta(array $metaControl, array $stored, array $submitted): array
157    {
158        foreach ($metaControl['props']['fields'] ?? [] as $sub) {
159            $key = $sub['key'] ?? null;
160
161            if (!is_string($key) || !is_array($submitted[$key] ?? null)) {
162                continue;
163            }
164
165            $map = is_array($stored[$key] ?? null) ? $stored[$key] : [];
166
167            foreach ($submitted[$key] as $locale => $raw) {
168                $map[$locale] = $this->cast($sub['control'] ?? [], $raw, $map[$locale] ?? null);
169            }
170
171            $stored[$key] = $map;
172        }
173
174        return $stored;
175    }
176
177    /** @param array<string, mixed> $control */
178    private function cast(
179        array $control,
180        mixed $raw,
181        mixed $stored,
182        ?DateTimeZone $timezone = null,
183    ): mixed {
184        $name = $control['name'] ?? '';
185        $props = $control['props'] ?? [];
186
187        if ($name === 'group') {
188            // Replace only the keys the descriptor knows; anything else
189            // stored inside the group survives.
190            $result = is_array($stored) ? $stored : [];
191
192            foreach ($props['fields'] ?? [] as $sub) {
193                $key = $sub['key'] ?? null;
194
195                if (!is_string($key) || !is_array($raw) || !array_key_exists($key, $raw)) {
196                    continue;
197                }
198
199                $result[$key] = $this->cast(
200                    $sub['control'] ?? [],
201                    $raw[$key],
202                    $result[$key] ?? null,
203                    $timezone,
204                );
205            }
206
207            return $result;
208        }
209
210        if ($name === 'repeater') {
211            // Lists are replaced wholesale; index gaps left by removed
212            // rows are normalized away.
213            $item = $props['item'] ?? [];
214
215            return array_map(
216                fn(mixed $rawItem): mixed => $this->cast($item, $rawItem, null, $timezone),
217                is_array($raw) ? array_values($raw) : [],
218            );
219        }
220
221        if ($name === 'entries' || $name === 'blocks') {
222            $rows = is_array($raw) ? array_values($raw) : [];
223            $stored = is_array($stored) ? $stored : [];
224
225            return $name === 'entries'
226                ? $this->entries($props, $rows, $stored)
227                : $this->blocks($props, $rows, $stored);
228        }
229
230        return match ($name) {
231            'checkbox' => $props['nullable'] ?? false
232                ? match ($raw) {
233                    '', null => null,
234                    '1', true => true,
235                    '0', false => false,
236                    default => $raw,
237                }
238                : $raw === '1' || $raw === 'on' || $raw === true,
239            'number' => is_numeric($raw) ? (float) $raw : null,
240            'datetime' => $this->datetime($raw, $timezone ?? Codec::utc()),
241            default => is_scalar($raw) ? (string) $raw : null,
242        };
243    }
244
245    private function datetime(mixed $raw, DateTimeZone $timezone): mixed
246    {
247        if (!is_scalar($raw)) {
248            return null;
249        }
250
251        $value = (string) $raw;
252
253        return Codec::fromInput($value, $timezone) ?? Codec::normalize($value) ?? $value;
254    }
255
256    /** @param array<array-key, mixed> $entry */
257    private function timezone(array $entry): DateTimeZone
258    {
259        $timezone = $entry['meta']['timezone'] ?? null;
260
261        if (is_array($timezone)) {
262            $timezone = $timezone[Field::NEUTRAL_LOCALE] ?? null;
263        }
264
265        return Codec::timezone($timezone) ?? Codec::utc();
266    }
267
268    /**
269     * @param array<string, mixed> $props
270     * @param list<mixed> $rows
271     * @param array<array-key, mixed> $stored
272     * @return list<array<array-key, mixed>>
273     */
274    private function entries(array $props, array $rows, array $stored): array
275    {
276        return $this->rows(
277            self::rowTypes($props['entryTypes'] ?? []),
278            $rows,
279            $stored,
280            static fn(array $storedRow, array $row, string $uid, string $type, array $fields): array => [
281                ...$storedRow,
282                'uid' => $uid,
283                'type' => $type,
284                'fields' => $fields,
285            ],
286        );
287    }
288
289    /**
290     * Block rows add the layout â€” ints clamped into the area the block is
291     * laid out on, the field's grid or the split it sits in, so a stored
292     * out-of-range value the editor loaded saves back clamped, where the
293     * shape would reject it â€” and the block meta map, patched like a
294     * field's meta against the descriptor's meta group. Blocks are matched
295     * by uid against every stored block, so one the editor moved into or
296     * out of a split keeps whatever the form does not carry.
297     *
298     * @param array<string, mixed> $props
299     * @param list<mixed> $rows
300     * @param array<array-key, mixed> $stored
301     * @return list<mixed>
302     */
303    private function blocks(array $props, array $rows, array $stored): array
304    {
305        $columns = is_int($props['columns'] ?? null) && $props['columns'] > 0 ? $props['columns'] : 1;
306        $min = is_int($props['min'] ?? null) && $props['min'] > 0 ? min($props['min'], $columns) : 1;
307        $metaControl = is_array($props['meta'] ?? null) ? $props['meta'] : null;
308        $types = self::rowTypes($props['blockTypes'] ?? []);
309        $known = $stored;
310
311        foreach ($stored as $storedRow) {
312            if (is_array($storedRow) && is_array($storedRow['blocks'] ?? null)) {
313                array_push($known, ...array_values($storedRow['blocks']));
314            }
315        }
316
317        // The blocks of a split have no position, not even one stored from
318        // before they were moved into it.
319        $block = fn(int $columns, int $rowspan, bool $part = false): Closure => fn(
320            array $storedRow,
321            array $row,
322            string $uid,
323            string $type,
324            array $fields,
325        ): array => $this->withMeta(
326            [
327                ...$storedRow,
328                'uid' => $uid,
329                'type' => $type,
330                'layout' => self::area(
331                    Layout::normalize(self::layout($storedRow, $row), $columns, $min, $rowspan),
332                    $part,
333                ),
334                'fields' => $fields,
335            ],
336            $storedRow,
337            $row,
338            $metaControl,
339        );
340
341        // A row that arrives without a position gets one below the others.
342        return Placement::rows(
343            $this->rows(
344                $types,
345                $rows,
346                $known,
347                $block($columns, Layout::MAX_ROWSPAN),
348                function (array $storedRow, array $row, string $uid) use (
349                    $types,
350                    $known,
351                    $block,
352                    $columns,
353                    $min,
354                    $metaControl,
355                ): ?array {
356                    $layout = Layout::normalize(self::layout($storedRow, $row), $columns, $min);
357                    $blocks = $this->rows(
358                        $types,
359                        array_values($row['blocks']),
360                        $known,
361                        $block($layout->colspan, $layout->rowspan, part: true),
362                    );
363
364                    // A split holds two blocks at least; the one left takes its place.
365                    if (count($blocks) < 2) {
366                        return $blocks === [] ? null : [...$blocks[0], 'layout' => $layout->array()];
367                    }
368
369                    return $this->withMeta(
370                        [...$storedRow, 'uid' => $uid, 'layout' => $layout->array(), 'blocks' => $blocks],
371                        $storedRow,
372                        $row,
373                        $metaControl,
374                    );
375                },
376            ),
377            $columns,
378            $min,
379        );
380    }
381
382    /** @return array{colspan: int, rowspan: int, col?: int, row?: int} */
383    private static function area(Layout $layout, bool $part): array
384    {
385        return $part ? new Layout($layout->colspan, $layout->rowspan)->array() : $layout->array();
386    }
387
388    /**
389     * The stored layout with the submitted dimensions over it.
390     *
391     * @param array<array-key, mixed> $storedRow
392     * @param array<array-key, mixed> $row
393     * @return array<array-key, mixed>
394     */
395    private static function layout(array $storedRow, array $row): array
396    {
397        return [
398            ...(is_array($storedRow['layout'] ?? null) ? $storedRow['layout'] : []),
399            ...(is_array($row['layout'] ?? null) ? $row['layout'] : []),
400        ];
401    }
402
403    /**
404     * @param array<array-key, mixed> $result
405     * @param array<array-key, mixed> $storedRow
406     * @param array<array-key, mixed> $row
407     * @param ?array<string, mixed> $metaControl
408     * @return array<array-key, mixed>
409     */
410    private function withMeta(array $result, array $storedRow, array $row, ?array $metaControl): array
411    {
412        if ($metaControl !== null && is_array($row['meta'] ?? null)) {
413            $result['meta'] = $this->meta(
414                $metaControl,
415                is_array($storedRow['meta'] ?? null) ? $storedRow['meta'] : [],
416                $row['meta'],
417            );
418        }
419
420        return $result;
421    }
422
423    /**
424     * Rows are replaced wholesale like a repeater, but each row's fields
425     * are patched like a group: rows are matched to their stored
426     * counterpart by uid, so unknown keys survive edits and reorders.
427     * `$build` assembles the row from the matched stored row (empty when
428     * the type changed), the submitted row, the uid and the patched fields.
429     * A row without a type carrying `blocks` goes to `$split` when one is
430     * given, with its stored counterpart unless that one is a block; a
431     * null from it drops the row.
432     *
433     * @param array<string, array<array-key, mixed>> $types row type descriptors keyed by class
434     * @param array<array-key, mixed> $rows
435     * @param array<array-key, mixed> $stored
436     * @param Closure(array<array-key, mixed>, array<array-key, mixed>, string, string, array<array-key, mixed>): array<array-key, mixed> $build
437     * @param ?Closure(array<array-key, mixed>, array<array-key, mixed>, string): ?array<array-key, mixed> $split
438     * @return list<array<array-key, mixed>>
439     */
440    private function rows(array $types, array $rows, array $stored, Closure $build, ?Closure $split = null): array
441    {
442        $byUid = [];
443
444        foreach ($stored as $storedRow) {
445            $uid = is_array($storedRow) ? $storedRow['uid'] ?? null : null;
446
447            if (is_string($uid) && $uid !== '') {
448                $byUid[$uid] = $storedRow;
449            }
450        }
451
452        $result = [];
453
454        foreach ($rows as $row) {
455            if (!is_array($row)) {
456                continue;
457            }
458
459            $type = $row['type'] ?? null;
460            $isSplit = $split !== null && $type === null && is_array($row['blocks'] ?? null);
461
462            if (!$isSplit && (!is_string($type) || !isset($types[$type]))) {
463                continue;
464            }
465
466            $uid = $row['uid'] ?? null;
467
468            if (!is_string($uid) || $uid === '') {
469                // The client fills fresh uids on stamped rows; this is the
470                // safety net for rows arriving without one.
471                $uid = $this->uid->generate();
472            }
473
474            $storedRow = $byUid[$uid] ?? [];
475
476            if ($isSplit) {
477                $built = $split(isset($storedRow['type']) ? [] : $storedRow, $row, $uid);
478
479                if ($built !== null) {
480                    $result[] = $built;
481                }
482
483                continue;
484            }
485
486            if (($storedRow['type'] ?? null) !== $type) {
487                $storedRow = [];
488            }
489
490            $fields = is_array($storedRow['fields'] ?? null) ? $storedRow['fields'] : [];
491            $submitted = is_array($row['fields'] ?? null) ? $row['fields'] : [];
492
493            foreach ($types[$type]['fields'] ?? [] as $sub) {
494                $subName = $sub['name'] ?? null;
495
496                if (!is_string($subName) || !is_array($submitted[$subName] ?? null)) {
497                    continue;
498                }
499
500                $entry = is_array($fields[$subName] ?? null)
501                    ? $fields[$subName]
502                    : ['type' => $sub['type'] ?? null, 'value' => []];
503                $patched = $this->entry(
504                    is_array($sub['control'] ?? null) ? $sub['control'] : [],
505                    is_array($sub['metaControl'] ?? null) ? $sub['metaControl'] : null,
506                    $entry,
507                    $submitted[$subName],
508                );
509
510                if ($patched !== null) {
511                    $fields[$subName] = $patched;
512                }
513            }
514
515            $result[] = $build($storedRow, $row, $uid, $type, $fields);
516        }
517
518        return $result;
519    }
520
521    /** @return array<string, array<array-key, mixed>> */
522    private static function rowTypes(mixed $types): array
523    {
524        $result = [];
525
526        foreach (is_array($types) ? $types : [] as $type) {
527            if (is_array($type) && is_string($type['type'] ?? null)) {
528                $result[$type['type']] = $type;
529            }
530        }
531
532        return $result;
533    }
534}