Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
157 / 157
100.00% covered (success)
100.00%
21 / 21
CRAP
100.00% covered (success)
100.00%
1 / 1
BlockRowConverter
100.00% covered (success)
100.00%
157 / 157
100.00% covered (success)
100.00%
21 / 21
91
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 convert
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 report
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 walk
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 isBlocksField
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 looksLikeBlocks
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
12
 field
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
7
 valueMap
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 block
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
8
 layout
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 fields
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
10
 richtext
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 youtube
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 scalar
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 media
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 pick
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 blockMeta
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 isEmpty
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 int
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
5
 count
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 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\Block\Heading;
8use Cosray\Block\Iframe;
9use Cosray\Block\Image;
10use Cosray\Block\Images;
11use Cosray\Block\RichText;
12use Cosray\Block\Video;
13use Cosray\Block\Youtube;
14use Cosray\Exception\RuntimeException;
15use Cosray\Field;
16use Cosray\Uid;
17
18/**
19 * Reshapes legacy blocks fields into typed rows for migration
20 * 000000-000031: `{type: id, colspan, rowspan, colstart, width, value,
21 * meta}` becomes `{uid, type: class, layout: {colspan, rowspan, indent},
22 * fields, meta?}`.
23 *
24 * Layouts are copied, never clamped against the field's columns — the
25 * schema is unknown here and every reader clamps. Rows that already
26 * carry `layout` and `fields`, blocks of unknown type and fields that
27 * are not blocks pass through untouched; the report lists what was
28 * converted, generated, dropped and skipped.
29 */
30final class BlockRowConverter
31{
32    private const string ZXX = Field\Field::NEUTRAL_LOCALE;
33
34    /** The implicit column count every legacy span was measured in. */
35    private const int LEGACY_COLUMNS = 12;
36
37    /** @var array<string, string> Legacy type id to block type name, as stored. */
38    private const array TYPES = [
39        'richtext' => RichText::class,
40        'html' => RichText::class,
41        // Migration 000000-000041 turns these into rich text.
42        'text' => TextBlocks::TYPE,
43        'h1' => Heading::class,
44        'h2' => Heading::class,
45        'h3' => Heading::class,
46        'h4' => Heading::class,
47        'h5' => Heading::class,
48        'h6' => Heading::class,
49        'image' => Image::class,
50        'images' => Images::class,
51        'video' => Video::class,
52        'youtube' => Youtube::class,
53        'iframe' => Iframe::class,
54    ];
55
56    private const array ASPECT_RATIO = ['aspectRatioX', 'aspectRatioY'];
57    private const array FIELD_META = ['columns', 'minCellWidth'];
58
59    /** @var array<string, mixed> */
60    private array $report = [
61        'fields' => 0,
62        'blocks' => 0,
63        'types' => [],
64        'uidsGenerated' => 0,
65        'legacyRichtext' => 0,
66        'droppedMediaItems' => 0,
67        'droppedItems' => 0,
68        'metaKeys' => [],
69        'unknownTypes' => [],
70        'unresolvedFieldTypes' => [],
71    ];
72
73    private string $table = '';
74    private string $row = '';
75
76    public function __construct(
77        private readonly Uid $uid,
78    ) {}
79
80    /**
81     * @param array<string, mixed> $content
82     * @param string $table Where the content came from, for the report.
83     * @param string $row The row's key within that table, for the report.
84     * @return array<mixed>
85     */
86    public function convert(array $content, string $table = '', string $row = ''): array
87    {
88        $this->table = $table;
89        $this->row = $row;
90
91        return $this->walk($content, '');
92    }
93
94    /** @return array<string, mixed> */
95    public function report(): array
96    {
97        return $this->report;
98    }
99
100    /**
101     * @param array<mixed> $data
102     * @return array<mixed>
103     */
104    private function walk(array $data, string $path): array
105    {
106        if ($this->isBlocksField($data)) {
107            return $this->field($data, $path);
108        }
109
110        foreach ($data as $key => $value) {
111            if (is_array($value)) {
112                $data[$key] = $this->walk($value, $path === '' ? (string) $key : "{$path}.{$key}");
113            }
114        }
115
116        return $data;
117    }
118
119    /** @param array<mixed> $data */
120    private function isBlocksField(array $data): bool
121    {
122        $type = $data['type'] ?? null;
123        $value = $data['value'] ?? null;
124
125        if (!is_string($type) || !is_array($value)) {
126            return false;
127        }
128
129        return is_a($type, Field\Blocks::class, true) || $this->looksLikeBlocks($value);
130    }
131
132    /**
133     * A locale map of lists whose items carry `colspan`: the stored
134     * field class no longer autoloads, the value still says blocks.
135     *
136     * @param array<mixed> $value
137     */
138    private function looksLikeBlocks(array $value): bool
139    {
140        if ($value === [] || array_is_list($value)) {
141            return false;
142        }
143
144        $found = false;
145
146        foreach ($value as $locale => $list) {
147            if (!is_string($locale) || !$this->isLocaleKey($locale)) {
148                return false;
149            }
150
151            if ($list === null) {
152                continue;
153            }
154
155            if (!is_array($list) || !array_is_list($list)) {
156                return false;
157            }
158
159            foreach ($list as $item) {
160                if (is_array($item) && array_key_exists('colspan', $item)) {
161                    $found = true;
162                }
163            }
164        }
165
166        return $found;
167    }
168
169    /**
170     * @param array<mixed> $data
171     * @return array<mixed>
172     */
173    private function field(array $data, string $path): array
174    {
175        $this->report['fields']++;
176
177        if (!is_a($data['type'], Field\Blocks::class, true)) {
178            $this->count('unresolvedFieldTypes', $data['type']);
179        }
180
181        $result = [];
182
183        foreach ($data as $key => $entry) {
184            if ($key === 'value') {
185                $result['value'] = $this->valueMap($entry, $path);
186
187                continue;
188            }
189
190            if ($key === 'meta') {
191                $meta = is_array($entry) ? array_diff_key($entry, array_flip(self::FIELD_META)) : [];
192
193                if ($meta !== []) {
194                    $result['meta'] = $meta;
195                }
196
197                continue;
198            }
199
200            $result[$key] = $entry;
201        }
202
203        return $result;
204    }
205
206    /**
207     * @param array<mixed> $value
208     * @return array<mixed>
209     */
210    private function valueMap(array $value, string $path): array
211    {
212        foreach ($value as $locale => $list) {
213            if (!is_array($list)) {
214                continue;
215            }
216
217            $rows = [];
218
219            foreach ($list as $index => $item) {
220                if (!is_array($item)) {
221                    $this->report['droppedItems']++;
222
223                    continue;
224                }
225
226                $rows[] = $this->block($item, (string) $locale, $path, (int) $index);
227            }
228
229            $value[$locale] = $rows;
230        }
231
232        return $value;
233    }
234
235    /**
236     * @param array<string, mixed> $block
237     * @return array<mixed>
238     */
239    private function block(array $block, string $locale, string $path, int $index): array
240    {
241        if (isset($block['layout'], $block['fields'])) {
242            return $block;
243        }
244
245        $type = $block['type'] ?? null;
246        $class = is_string($type) ? self::TYPES[$type] ?? null : null;
247
248        if ($class === null) {
249            $this->report['unknownTypes'][] = [
250                'table' => $this->table,
251                'row' => $this->row,
252                'field' => $path,
253                'locale' => $locale,
254                'index' => $index,
255                'type' => $type,
256            ];
257
258            return $block;
259        }
260
261        $this->report['blocks']++;
262        $this->count('types', $type);
263        $uid = $block['uid'] ?? null;
264
265        if (!is_string($uid) || $uid === '') {
266            $uid = $this->uid->generate();
267            $this->report['uidsGenerated']++;
268        }
269
270        $result = [
271            'uid' => $uid,
272            'type' => $class,
273            'layout' => $this->layout($block),
274            'fields' => $this->fields($type, $block, $locale),
275        ];
276        $meta = $this->blockMeta($type, is_array($block['meta'] ?? null) ? $block['meta'] : []);
277
278        if ($meta !== []) {
279            $result['meta'] = $meta;
280        }
281
282        return $result;
283    }
284
285    /**
286     * Values below the range no schema could accept are floored; the
287     * field's real bounds are applied by the readers and the shape.
288     *
289     * @param array<mixed> $block
290     * @return array{colspan: int, rowspan: int, indent: int}
291     */
292    private function layout(array $block): array
293    {
294        $colstart = $this->int($block['colstart'] ?? null);
295
296        return [
297            'colspan' => max(1, $this->int($block['colspan'] ?? null) ?? self::LEGACY_COLUMNS),
298            'rowspan' => max(1, $this->int($block['rowspan'] ?? null) ?? 1),
299            'indent' => $colstart === null ? 0 : max(0, $colstart - 1),
300        ];
301    }
302
303    /**
304     * @param array<mixed> $block
305     * @return array<string, array<mixed>>
306     */
307    private function fields(string $type, array $block, string $locale): array
308    {
309        $value = $block['value'] ?? null;
310
311        return match ($type) {
312            'richtext', 'html' => ['text' => $this->richtext($block, $locale)],
313            'text' => ['text' => $this->scalar(Field\Textarea::class, $value, $locale)],
314            'h1', 'h2', 'h3', 'h4', 'h5', 'h6' => [
315                'text' => $this->scalar(Field\Text::class, $value, $locale),
316                'level' => ['type' => Field\Option::class, 'value' => [self::ZXX => substr($type, 1)]],
317            ],
318            'image' => ['image' => $this->media(Field\Image::class, $value, $locale, 1)],
319            'images' => ['images' => $this->media(Field\Image::class, $value, $locale, null)],
320            'video' => ['video' => $this->media(Field\Video::class, $value, $locale, 1)],
321            'youtube' => ['video' => $this->youtube($block, $locale)],
322            'iframe' => ['code' => $this->scalar(Field\Iframe::class, $value, $locale)],
323            default => throw new RuntimeException("No fields for legacy block type '{$type}'"),
324        };
325    }
326
327    /**
328     * The envelope moves with the document. A block without one is
329     * legacy HTML the richtext migration has not seen; it keeps its
330     * markless value so that migration still recognizes it.
331     *
332     * @param array<mixed> $block
333     * @return array<mixed>
334     */
335    private function richtext(array $block, string $locale): array
336    {
337        $result = ['type' => Field\RichText::class];
338
339        if (is_string($block['format'] ?? null) && $block['format'] !== '') {
340            $result['format'] = $block['format'];
341            $result['version'] = $block['version'] ?? null;
342        } else {
343            $this->report['legacyRichtext']++;
344        }
345
346        $result['value'] = [self::ZXX => $this->pick($block['value'] ?? null, $locale)];
347
348        return $result;
349    }
350
351    /**
352     * @param array<mixed> $block
353     * @return array<mixed>
354     */
355    private function youtube(array $block, string $locale): array
356    {
357        $result = [
358            'type' => Field\Youtube::class,
359            'value' => [self::ZXX => $this->pick($block['value'] ?? null, $locale)],
360        ];
361        $meta = is_array($block['meta'] ?? null) ? $block['meta'] : [];
362
363        foreach (self::ASPECT_RATIO as $key) {
364            if (array_key_exists($key, $meta)) {
365                $result['meta'][$key] = $meta[$key];
366            }
367        }
368
369        return $result;
370    }
371
372    /** @return array<mixed> */
373    private function scalar(string $class, mixed $value, string $locale): array
374    {
375        return ['type' => $class, 'value' => [self::ZXX => $this->pick($value, $locale)]];
376    }
377
378    /** @return array<mixed> */
379    private function media(string $class, mixed $value, string $locale, ?int $limit): array
380    {
381        $items = $this->pick($value, $locale);
382        $items = is_array($items) ? array_values(array_filter($items, is_array(...))) : [];
383
384        if ($limit !== null && count($items) > $limit) {
385            $this->report['droppedMediaItems'] += count($items) - $limit;
386            $items = array_slice($items, 0, $limit);
387        }
388
389        return ['type' => $class, 'value' => [self::ZXX => $items]];
390    }
391
392    /**
393     * A block's value: lists (media items) as they are; of a locale map
394     * the neutral entry, else the entry of the list's own locale, else
395     * the first one — a sub-field of a per-locale or untranslated list
396     * is untranslated.
397     */
398    private function pick(mixed $value, string $locale): mixed
399    {
400        if (!is_array($value)) {
401            return $value;
402        }
403
404        if ($value === []) {
405            return null;
406        }
407
408        if (array_is_list($value)) {
409            return $value;
410        }
411
412        if (array_key_exists(self::ZXX, $value)) {
413            return $value[self::ZXX];
414        }
415
416        return array_key_exists($locale, $value) ? $value[$locale] : reset($value);
417    }
418
419    /**
420     * Block meta minus the YouTube aspect ratio, which moved into the
421     * field, and minus empty entries, so the report counts real uses.
422     *
423     * @param array<mixed> $meta
424     * @return array<mixed>
425     */
426    private function blockMeta(string $type, array $meta): array
427    {
428        if ($type === 'youtube') {
429            $meta = array_diff_key($meta, array_flip(self::ASPECT_RATIO));
430        }
431
432        $result = [];
433
434        foreach ($meta as $key => $value) {
435            if ($this->isEmpty($value)) {
436                continue;
437            }
438
439            $result[$key] = $value;
440            $this->count('metaKeys', (string) $key);
441        }
442
443        return $result;
444    }
445
446    private function isEmpty(mixed $value): bool
447    {
448        if (!is_array($value)) {
449            return $value === null || $value === '';
450        }
451
452        foreach ($value as $entry) {
453            if (!$this->isEmpty($entry)) {
454                return false;
455            }
456        }
457
458        return true;
459    }
460
461    private function int(mixed $value): ?int
462    {
463        return is_int($value) || is_string($value) && is_numeric($value) || is_float($value) ? (int) $value : null;
464    }
465
466    private function count(string $bucket, string $key): void
467    {
468        $this->report[$bucket][$key] = ($this->report[$bucket][$key] ?? 0) + 1;
469    }
470
471    private function isLocaleKey(string $key): bool
472    {
473        return $key === self::ZXX || preg_match('/^[a-z]{2}(?:[-_][A-Za-z0-9]{2,8})?$/', $key) === 1;
474    }
475}