Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.98% covered (warning)
86.98%
147 / 169
47.83% covered (danger)
47.83%
11 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
NodeContentNormalizer
86.98% covered (warning)
86.98%
147 / 169
47.83% covered (danger)
47.83%
11 / 23
103.70
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
 normalize
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 field
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 blocksField
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 entriesField
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
2.01
 mediaField
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
2.01
 fieldType
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
5.20
 valueMap
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 blockValueMap
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
8.74
 blockList
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
4.25
 block
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
8
 mediaValueMap
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 mediaList
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
4.25
 mediaItem
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 selectPicture
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
5.58
 entryValueMap
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
8.74
 entryList
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
4.25
 entry
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 fieldMeta
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 normalizeMeta
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 normalizeMetaLeaf
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 isLocaleMap
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
6.17
 isLocaleKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Migration;
6
7use Cosray\Field;
8use Cosray\Uid;
9
10final class NodeContentNormalizer
11{
12    private const string ZXX = Field\Field::NEUTRAL_LOCALE;
13
14    /** @var array<string, class-string<Field\Field>> */
15    private const array FIELD_TYPES = [
16        'blocks' => Field\Blocks::class,
17        'checkbox' => Field\Checkbox::class,
18        'code' => Field\Code::class,
19        'date' => Field\Date::class,
20        'datetime' => Field\DateTime::class,
21        'decimal' => Field\Decimal::class,
22        'entries' => Field\Entries::class,
23        'file' => Field\File::class,
24        'grid' => Field\Blocks::class,
25        'html' => Field\RichText::class,
26        'iframe' => Field\Iframe::class,
27        'image' => Field\Image::class,
28        'matrix' => Field\Entries::class,
29        'number' => Field\Number::class,
30        'option' => Field\Option::class,
31        'picture' => Field\Image::class,
32        'radio' => Field\Radio::class,
33        'richtext' => Field\RichText::class,
34        'text' => Field\Text::class,
35        'textarea' => Field\Textarea::class,
36        'time' => Field\Time::class,
37        'video' => Field\Video::class,
38        'youtube' => Field\Youtube::class,
39    ];
40
41    /** @var array<class-string<Field\Field>, true> */
42    private const array MEDIA_TYPES = [
43        Field\File::class => true,
44        Field\Image::class => true,
45        Field\Video::class => true,
46    ];
47
48    public function __construct(
49        private readonly Uid $uid,
50    ) {}
51
52    /** @param array<string, mixed> $content */
53    public function normalize(array $content): array
54    {
55        $result = [];
56
57        foreach ($content as $name => $field) {
58            if (!is_array($field)) {
59                continue;
60            }
61
62            $result[$name] = $this->field($field);
63        }
64
65        return $result;
66    }
67
68    /** @param array<string, mixed> $data */
69    private function field(array $data): array
70    {
71        $type = $this->fieldType($data['type'] ?? null);
72
73        if ($type === Field\Blocks::class) {
74            return $this->blocksField($data, $type);
75        }
76
77        if ($type === Field\Entries::class) {
78            return $this->entriesField($data, $type);
79        }
80
81        if (isset(self::MEDIA_TYPES[$type])) {
82            return $this->mediaField($data, $type, ($data['type'] ?? null) === 'picture');
83        }
84
85        $result = [
86            'type' => $type,
87            'value' => $this->valueMap($data['value'] ?? null),
88        ];
89        $meta = $this->fieldMeta($data, ['type', 'value']);
90
91        if ($meta !== []) {
92            $result['meta'] = $meta;
93        }
94
95        return $result;
96    }
97
98    /** @param array<string, mixed> $data */
99    private function blocksField(array $data, string $type): array
100    {
101        $value = $data['value'] ?? $data['items'] ?? [];
102        $result = [
103            'type' => $type,
104            'value' => $this->blockValueMap($value),
105        ];
106        $meta = $this->fieldMeta($data, ['type', 'value', 'items']);
107
108        if ($meta !== []) {
109            $result['meta'] = $meta;
110        }
111
112        return $result;
113    }
114
115    /** @param array<string, mixed> $data */
116    private function entriesField(array $data, string $type): array
117    {
118        $value = $data['value'] ?? [];
119        $result = [
120            'type' => $type,
121            'value' => $this->entryValueMap($value),
122        ];
123        $meta = $this->fieldMeta($data, ['type', 'value']);
124
125        if ($meta !== []) {
126            $result['meta'] = $meta;
127        }
128
129        return $result;
130    }
131
132    /** @param array<string, mixed> $data */
133    private function mediaField(array $data, string $type, bool $picture): array
134    {
135        $value = $data['value'] ?? $data['files'] ?? [];
136        $result = [
137            'type' => $type,
138            'value' => $this->mediaValueMap($value, $picture),
139        ];
140        $meta = $this->fieldMeta($data, ['type', 'value', 'files']);
141
142        if ($meta !== []) {
143            $result['meta'] = $meta;
144        }
145
146        return $result;
147    }
148
149    private function fieldType(mixed $type): string
150    {
151        if (is_string($type) && isset(self::FIELD_TYPES[$type])) {
152            return self::FIELD_TYPES[$type];
153        }
154
155        if (is_string($type) && is_subclass_of($type, Field\Field::class)) {
156            return $type;
157        }
158
159        return Field\Text::class;
160    }
161
162    private function valueMap(mixed $value): array
163    {
164        if (is_array($value) && $this->isLocaleMap($value)) {
165            return $value;
166        }
167
168        return [self::ZXX => $value];
169    }
170
171    private function blockValueMap(mixed $value): array
172    {
173        if (is_array($value) && $this->isLocaleMap($value)) {
174            $result = [];
175
176            foreach ($value as $locale => $items) {
177                $result[$locale] = $this->blockList($items);
178            }
179
180            return $result;
181        }
182
183        return [self::ZXX => $this->blockList($value)];
184    }
185
186    private function blockList(mixed $items): array
187    {
188        if (!is_array($items)) {
189            return [];
190        }
191
192        $result = [];
193
194        foreach ($items as $item) {
195            if (!is_array($item)) {
196                continue;
197            }
198
199            $result[] = $this->block($item);
200        }
201
202        return $result;
203    }
204
205    /** @param array<string, mixed> $data */
206    private function block(array $data): array
207    {
208        $type = is_string($data['type'] ?? null) ? $data['type'] : 'text';
209        $result = ['type' => $type];
210
211        foreach (['uid', 'width', 'colspan', 'rowspan', 'colstart'] as $key) {
212            if (!array_key_exists($key, $data)) {
213                continue;
214            }
215
216            $result[$key] = $data[$key];
217        }
218
219        if (in_array($type, ['image', 'images', 'video'], true)) {
220            $value = $data['value'] ?? $data['files'] ?? [];
221            $list = $this->mediaList($value);
222            $result['value'] = $type === 'image' ? array_slice($list, 0, 1) : $list;
223        } elseif ($type === 'youtube') {
224            $result['value'] = $this->valueMap($data['value'] ?? $data['id'] ?? null);
225        } else {
226            $result['value'] = $this->valueMap($data['value'] ?? null);
227        }
228
229        $meta = $this->fieldMeta($data, [
230            'type',
231            'uid',
232            'width',
233            'colspan',
234            'rowspan',
235            'colstart',
236            'value',
237            'files',
238        ]);
239
240        if ($meta !== []) {
241            $result['meta'] = $meta;
242        }
243
244        return $result;
245    }
246
247    private function mediaValueMap(mixed $value, bool $picture): array
248    {
249        if (is_array($value) && $this->isLocaleMap($value)) {
250            $result = [];
251
252            foreach ($value as $locale => $items) {
253                $list = $this->mediaList($items);
254                $result[$locale] = $picture ? $this->selectPicture($list) : $list;
255            }
256
257            return $result;
258        }
259
260        $list = $this->mediaList($value);
261
262        return [self::ZXX => $picture ? $this->selectPicture($list) : $list];
263    }
264
265    private function mediaList(mixed $items): array
266    {
267        if (!is_array($items)) {
268            return [];
269        }
270
271        $result = [];
272
273        foreach ($items as $item) {
274            if (!is_array($item)) {
275                continue;
276            }
277
278            $result[] = $this->mediaItem($item);
279        }
280
281        return $result;
282    }
283
284    /** @param array<string, mixed> $item */
285    private function mediaItem(array $item): array
286    {
287        $result = ['file' => $item['file'] ?? ''];
288        $meta = $this->fieldMeta($item, ['file']);
289
290        if ($meta !== []) {
291            $result['meta'] = $meta;
292        }
293
294        return $result;
295    }
296
297    private function selectPicture(array $items): array
298    {
299        if ($items === []) {
300            return [];
301        }
302
303        foreach ($items as $item) {
304            $file = is_string($item['file'] ?? null) ? $item['file'] : '';
305
306            if (strtolower(pathinfo($file, PATHINFO_EXTENSION)) === 'webp') {
307                return [$item];
308            }
309        }
310
311        return [$items[0]];
312    }
313
314    private function entryValueMap(mixed $value): array
315    {
316        if (is_array($value) && $this->isLocaleMap($value)) {
317            $result = [];
318
319            foreach ($value as $locale => $entries) {
320                $result[$locale] = $this->entryList($entries);
321            }
322
323            return $result;
324        }
325
326        return [self::ZXX => $this->entryList($value)];
327    }
328
329    private function entryList(mixed $entries): array
330    {
331        if (!is_array($entries)) {
332            return [];
333        }
334
335        $result = [];
336
337        foreach ($entries as $entry) {
338            if (!is_array($entry)) {
339                continue;
340            }
341
342            $result[] = $this->entry($entry);
343        }
344
345        return $result;
346    }
347
348    /** @param array<string, mixed> $entry */
349    private function entry(array $entry): array
350    {
351        $fields = $entry['fields'] ?? $entry['value'] ?? [];
352
353        return [
354            'uid' => is_string($entry['uid'] ?? null) && $entry['uid'] !== ''
355                ? $entry['uid']
356                : $this->uid->generate(),
357            'type' => is_string($entry['type'] ?? null) ? $entry['type'] : '',
358            'fields' => is_array($fields) ? $this->normalize($fields) : [],
359        ];
360    }
361
362    /** @param list<string> $skip */
363    private function fieldMeta(array $data, array $skip): array
364    {
365        $meta = [];
366
367        if (is_array($data['meta'] ?? null)) {
368            $meta = $this->normalizeMeta($data['meta']);
369        }
370
371        foreach ($data as $key => $value) {
372            if (in_array($key, $skip, true) || $key === 'meta') {
373                continue;
374            }
375
376            $meta[$key] = $this->normalizeMetaLeaf($value);
377        }
378
379        return $meta;
380    }
381
382    private function normalizeMeta(array $meta): array
383    {
384        $result = [];
385
386        foreach ($meta as $key => $value) {
387            $result[$key] = $this->normalizeMetaLeaf($value);
388        }
389
390        return $result;
391    }
392
393    private function normalizeMetaLeaf(mixed $value): array
394    {
395        if (is_array($value) && $this->isLocaleMap($value)) {
396            return $value;
397        }
398
399        return [self::ZXX => $value];
400    }
401
402    private function isLocaleMap(array $value): bool
403    {
404        if ($value === [] || array_is_list($value)) {
405            return false;
406        }
407
408        foreach (array_keys($value) as $key) {
409            if (!is_string($key) || !$this->isLocaleKey($key)) {
410                return false;
411            }
412        }
413
414        return true;
415    }
416
417    private function isLocaleKey(string $key): bool
418    {
419        return $key === self::ZXX || preg_match('/^[a-z]{2}(?:[-_][A-Za-z0-9]{2,8})?$/', $key) === 1;
420    }
421}