Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.51% covered (success)
98.51%
66 / 67
87.50% covered (warning)
87.50%
7 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
EntrySummary
98.51% covered (success)
98.51%
66 / 67
87.50% covered (warning)
87.50%
7 / 8
41
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
 of
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
11
 textLike
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 text
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 plain
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 thumb
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
9
 localized
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 docText
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Panel;
6
7use Cosray\Field\Decimal;
8use Cosray\Field\Image;
9use Cosray\Field\Number;
10use Cosray\Field\RichText;
11use Cosray\Field\Text;
12use Cosray\Field\Textarea;
13
14/**
15 * What a collapsed entry row shows: the first two text-like sub-fields
16 * carrying content and the first image sub-field carrying an asset,
17 * walking the entry type's fields in declaration order. Nothing is
18 * configured; the order of the entry class's properties decides.
19 */
20final class EntrySummary
21{
22    private const int LENGTH = 60;
23
24    /**
25     * @param ?string $primaryField name of the sub-field behind `primary`
26     * @param ?string $secondaryField name of the sub-field behind `secondary`
27     */
28    public function __construct(
29        public readonly ?string $primary,
30        public readonly ?string $secondary,
31        public readonly ?string $thumb,
32        public readonly bool $hasImage,
33        public readonly ?string $primaryField = null,
34        public readonly ?string $secondaryField = null,
35    ) {}
36
37    /**
38     * @param array<string, mixed> $entryType the descriptor's entry type row
39     * @param array<string, mixed> $fieldsData the stored row's `fields` map
40     * @param array<string, array<string, mixed>> $assets the node's asset map
41     */
42    public static function of(
43        array $entryType,
44        array $fieldsData,
45        array $assets,
46        string $locale,
47    ): self {
48        $texts = [];
49        $thumb = null;
50        $hasImage = false;
51
52        foreach ((array) ($entryType['fields'] ?? []) as $sub) {
53            if (!is_array($sub) || !is_string($sub['name'] ?? null) || !is_string($sub['type'] ?? null)) {
54                continue;
55            }
56
57            $type = $sub['type'];
58            $data = $fieldsData[$sub['name']] ?? null;
59            $value = is_array($data) && is_array($data['value'] ?? null) ? $data['value'] : [];
60
61            if (is_a($type, Image::class, true)) {
62                $hasImage = true;
63                $thumb ??= self::thumb($value, $assets, $locale);
64
65                continue;
66            }
67
68            if (count($texts) >= 2 || !self::textLike($type)) {
69                continue;
70            }
71
72            $text = self::text($value, $locale, is_a($type, RichText::class, true));
73
74            if ($text !== null) {
75                $texts[] = [$sub['name'], $text];
76            }
77        }
78
79        return new self(
80            $texts[0][1] ?? null,
81            $texts[1][1] ?? null,
82            $thumb,
83            $hasImage,
84            $texts[0][0] ?? null,
85            $texts[1][0] ?? null,
86        );
87    }
88
89    private static function textLike(string $type): bool
90    {
91        foreach ([Text::class, Textarea::class, Number::class, Decimal::class, RichText::class] as $class) {
92            if (is_a($type, $class, true)) {
93                return true;
94            }
95        }
96
97        return false;
98    }
99
100    /** @param array<string, mixed> $value */
101    private static function text(array $value, string $locale, bool $richtext): ?string
102    {
103        foreach (self::localized($value, $locale) as $localized) {
104            $text = trim((string) preg_replace('/\s+/u', ' ', self::plain($localized, $richtext)));
105
106            if ($text === '') {
107                continue;
108            }
109
110            return mb_strlen($text) > self::LENGTH
111                ? rtrim(mb_substr($text, 0, self::LENGTH)) . '…'
112                : $text;
113        }
114
115        return null;
116    }
117
118    private static function plain(mixed $localized, bool $richtext): string
119    {
120        if ($richtext) {
121            return is_array($localized) ? self::docText($localized) : '';
122        }
123
124        return is_scalar($localized) ? (string) $localized : '';
125    }
126
127    /**
128     * @param array<string, mixed> $value
129     * @param array<string, array<string, mixed>> $assets
130     */
131    private static function thumb(array $value, array $assets, string $locale): ?string
132    {
133        foreach (self::localized($value, $locale) as $localized) {
134            if (!is_array($localized)) {
135                continue;
136            }
137
138            foreach ($localized as $item) {
139                $uid = is_array($item) ? $item['uid'] ?? null : null;
140                $asset = is_string($uid) ? $assets[$uid] ?? null : null;
141                $url = is_array($asset) ? $asset['thumbUrl'] ?? $asset['url'] ?? null : null;
142
143                if (is_string($url) && $url !== '') {
144                    return $url;
145                }
146            }
147        }
148
149        return null;
150    }
151
152    /**
153     * The requested locale first, then the neutral one, then whatever
154     * else carries content — a row typed in one language only still
155     * gets a summary.
156     *
157     * @param array<string, mixed> $value
158     * @return list<mixed>
159     */
160    private static function localized(array $value, string $locale): array
161    {
162        $ordered = [];
163
164        foreach ([$locale, 'zxx'] as $key) {
165            if (array_key_exists($key, $value)) {
166                $ordered[$key] = $value[$key];
167            }
168        }
169
170        return array_values($ordered + $value);
171    }
172
173    /** @param array<string, mixed> $node */
174    private static function docText(array $node): string
175    {
176        if (is_string($node['text'] ?? null)) {
177            return $node['text'];
178        }
179
180        $parts = [];
181        $textblock = false;
182
183        foreach ((array) ($node['content'] ?? []) as $child) {
184            if (!is_array($child)) {
185                continue;
186            }
187
188            $textblock = $textblock || is_string($child['text'] ?? null);
189            $parts[] = self::docText($child);
190        }
191
192        // Block nodes end in a space so adjacent paragraphs do not run
193        // together; inline runs stay joined.
194        return implode('', $parts) . ($textblock ? ' ' : '');
195    }
196}