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