Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.98% covered (success)
91.98%
195 / 212
45.45% covered (danger)
45.45%
5 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Collection
91.98% covered (success)
91.98%
195 / 212
45.45% covered (danger)
45.45%
5 / 11
66.11
0.00% covered (danger)
0.00%
0 / 1
 collection
98.78% covered (success)
98.78%
81 / 82
0.00% covered (danger)
0.00%
0 / 1
20
 position
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
8
 notice
100.00% covered (success)
100.00%
52 / 52
100.00% covered (success)
100.00%
1 / 1
6
 parentNode
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 nodeStatus
57.14% covered (warning)
57.14%
12 / 21
0.00% covered (danger)
0.00%
0 / 1
15.38
 types
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 blueprints
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 intParam
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
9.36
 openParam
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 stringParam
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 formString
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Controller\Panel;
6
7use Celema\Core\Exception\HttpBadRequest;
8use Celema\Core\Exception\HttpNotFound;
9use Celema\Core\Factory\Factory;
10use Celema\Core\Response;
11use Cosray\Collection\Listing;
12use Cosray\Exception\RuntimeException;
13use Cosray\Node\Positions;
14use Cosray\Node\Types;
15use Cosray\Node\Wrapper;
16use Cosray\Panel\CollectionPage;
17use Cosray\Panel\CollectionQuery;
18use Cosray\Panel\CollectionTree;
19use Cosray\Panel\CollectionUrls;
20
21final class Collection extends Panel
22{
23    private const int LIMIT_DEFAULT = 50;
24    private const int LIMIT_MAX = 250;
25
26    /** @return array<string, mixed> */
27    public function collection(string $collection): array
28    {
29        $ref = $this->ref($collection);
30        $lister = $this->listing($ref);
31
32        $offset = $this->intParam('offset', 0, min: 0);
33        $limit = $this->intParam('limit', self::LIMIT_DEFAULT, min: 1, max: self::LIMIT_MAX);
34        $q = $this->stringParam('q');
35        $sort = $this->stringParam('sort');
36        $dir = strtolower($this->stringParam('dir'));
37        $parent = $this->stringParam('parent');
38        $view = $this->stringParam('view');
39        $open = $this->openParam('open');
40
41        if ($dir !== '' && !in_array($dir, ['asc', 'desc'], true)) {
42            throw new HttpBadRequest($this->request);
43        }
44
45        $sorts = $lister->sorts;
46
47        if ($sort !== '' && !array_key_exists($sort, $sorts)) {
48            throw new HttpBadRequest($this->request);
49        }
50
51        $parentUid = $lister->meta->showChildren && $parent !== '' ? $parent : null;
52        $defaultView = $lister->meta->showChildren && $parentUid === null ? 'tree' : 'list';
53
54        if ($view === '') {
55            $view = $defaultView;
56        }
57
58        if (!in_array($view, ['tree', 'list'], true)) {
59            throw new HttpBadRequest($this->request);
60        }
61
62        if (!$lister->meta->showChildren) {
63            $open = [];
64        }
65
66        $parentNode = $parentUid === null ? null : $this->parentNode($lister, $parentUid);
67        $parentTitle = $parentNode?->title();
68
69        if ($parentTitle !== null && trim($parentTitle) === '') {
70            $parentTitle = $parentUid;
71        }
72
73        $listing = $lister->list(
74            offset: $offset,
75            limit: $limit,
76            q: $q,
77            sort: $sort,
78            dir: $dir,
79            parent: $parentUid,
80        );
81
82        $query = new CollectionQuery(
83            q: $listing['q'],
84            sort: $listing['sort'],
85            dir: $listing['dir'],
86            offset: $listing['offset'],
87            limit: $listing['limit'],
88            parent: $parentUid,
89            view: $view,
90            open: $open,
91            defaultView: $defaultView,
92        );
93        $nodes = $listing['nodes'];
94
95        if ($lister->meta->showChildren && $view === 'tree') {
96            $nodes = CollectionTree::build(
97                nodes: $nodes,
98                open: $open,
99                children: static fn(string $uid): array => $lister->list(
100                    offset: 0,
101                    limit: self::LIMIT_MAX,
102                    sort: $listing['sort'],
103                    dir: $listing['dir'],
104                    parent: $uid,
105                )['nodes'],
106            );
107        }
108
109        $urls = new CollectionUrls($this->panelPath(), $collection, $query);
110
111        return $this->context([
112            'notice' => $this->notice(),
113            'page' => CollectionPage::from(
114                name: __($ref->meta->label),
115                urls: $urls,
116                columns: $lister->columns,
117                blueprints: $this->blueprints($lister),
118                nodes: $nodes,
119                total: $listing['total'],
120                meta: $lister->meta,
121                arranged: $listing['arranged'],
122                moved: $this->stringParam('moved'),
123                locale: $this->localeId(),
124                timezone: $this->config->app->timezone,
125                parentTitle: $parentTitle,
126                parentType: $parentNode === null
127                    ? null
128                    : __((string) $parentNode->meta->type->get('label', '')),
129                parentStatus: $parentNode === null ? null : $this->nodeStatus($lister, $parentNode),
130                createBlueprints: $parentNode === null ? null : $lister->childBlueprints($parentNode),
131            ),
132        ]);
133    }
134
135    /**
136     * Moves an entry in its manual order: `move` up or down by one row of
137     * the listing, or `before`/`after` a neighbour, as a drop sends it.
138     * Positions apply immediately, without a content version.
139     *
140     * Reordering changes the parent's children or the collection's top
141     * level, so once node permissions are enforced, this checks `change`
142     * on the parent or the collection's permission, not the moved node's.
143     */
144    public function position(Factory $factory, Positions $positions, string $collection): Response
145    {
146        $lister = $this->listing($this->ref($collection));
147        $form = $this->formData();
148        $node = $this->formString($form, 'node');
149        $move = $this->formString($form, 'move');
150        $before = $this->formString($form, 'before');
151        $after = $this->formString($form, 'after');
152
153        if ($node === '' || ((int) ($move !== '') + (int) ($before !== '') + (int) ($after !== '')) !== 1) {
154            throw new HttpBadRequest($this->request);
155        }
156
157        try {
158            if ($move !== '') {
159                if (!in_array($move, ['up', 'down'], true)) {
160                    throw new HttpBadRequest($this->request);
161                }
162
163                $neighbour = $lister->neighbour($node, below: $move === 'down');
164
165                if ($neighbour !== null) {
166                    $lister->place($positions, $node, $neighbour, after: $move === 'down');
167                }
168            } else {
169                $lister->place($positions, $node, $before !== '' ? $before : $after, after: $after !== '');
170            }
171        } catch (RuntimeException $e) {
172            throw new HttpBadRequest($this->request, previous: $e);
173        }
174
175        return $this->listingRedirect($factory, $collection, ['moved' => $node]);
176    }
177
178    /**
179     * The bulk-action result summary from the redirect's `notice` param
180     * (`key:count` pairs), reduced to display strings. Display-only, so
181     * unknown or malformed parts are dropped rather than rejected.
182     *
183     * @return list<string>
184     */
185    private function notice(): array
186    {
187        $value = $this->request->param('notice', '');
188
189        if (!is_string($value) || trim($value) === '') {
190            return [];
191        }
192
193        // Literal ids so the i18n scanner sees every key.
194        $keys = [
195            'deleted' => static fn(int $n): string => __n(
196                'bulk:notice-deleted',
197                'bulk:notice-deleted-plural',
198                $n,
199            ),
200            'published' => static fn(int $n): string => __n(
201                'bulk:notice-published',
202                'bulk:notice-published-plural',
203                $n,
204            ),
205            'unpublished' => static fn(int $n): string => __n(
206                'bulk:notice-unpublished',
207                'bulk:notice-unpublished-plural',
208                $n,
209            ),
210            'changes-published' => static fn(int $n): string => __n(
211                'bulk:notice-changes-published',
212                'bulk:notice-changes-published-plural',
213                $n,
214            ),
215            'duplicated' => static fn(int $n): string => __n(
216                'bulk:notice-duplicated',
217                'bulk:notice-duplicated-plural',
218                $n,
219            ),
220            'skipped-children' => static fn(int $n): string => __n(
221                'bulk:notice-skipped-children',
222                'bulk:notice-skipped-children-plural',
223                $n,
224            ),
225            'skipped-locked' => static fn(int $n): string => __n(
226                'bulk:notice-skipped-locked',
227                'bulk:notice-skipped-locked-plural',
228                $n,
229            ),
230            'skipped' => static fn(int $n): string => __n(
231                'bulk:notice-skipped',
232                'bulk:notice-skipped-plural',
233                $n,
234            ),
235        ];
236        $messages = [];
237
238        foreach (explode(',', $value) as $part) {
239            [$key, $count] = array_pad(explode(':', $part, 2), 2, '');
240
241            if (!isset($keys[$key]) || !preg_match('/^[1-9][0-9]{0,5}$/', $count)) {
242                continue;
243            }
244
245            $messages[] = $keys[$key]((int) $count);
246        }
247
248        return $messages;
249    }
250
251    private function parentNode(Listing $lister, string $uid): Wrapper
252    {
253        $node = $lister->parent($uid);
254
255        if (!$node) {
256            throw new HttpNotFound($this->request);
257        }
258
259        return $node;
260    }
261
262    /** @return list<array{kind: string, label: string}> */
263    private function nodeStatus(Listing $lister, Wrapper $node): array
264    {
265        $status = [];
266        $meta = $lister->meta;
267
268        if ($meta->showPublished) {
269            $published = (bool) $node->meta->get('published');
270            $status[] = [
271                'kind' => $published ? 'published' : 'unpublished',
272                'label' => $published ? __('status:published') : __('status:unpublished'),
273            ];
274
275            if ($node->meta->get('draft') !== null) {
276                $status[] = ['kind' => 'changes', 'label' => __('status:changes')];
277            }
278        }
279
280        if ($meta->showHidden && (bool) $node->meta->get('hidden')) {
281            $status[] = [
282                'kind' => 'hidden',
283                'label' => __('status:hidden'),
284            ];
285        }
286
287        if ($meta->showLocked && (bool) $node->meta->get('locked')) {
288            $status[] = [
289                'kind' => 'locked',
290                'label' => __('status:locked'),
291            ];
292        }
293
294        return $status;
295    }
296
297    private function types(): Types
298    {
299        return $this->container->instance(Types::class);
300    }
301
302    /** @return list<array{slug: string, name: string}> */
303    private function blueprints(Listing $lister): array
304    {
305        $types = $this->types();
306        $result = [];
307
308        foreach ($lister->blueprints as $blueprint) {
309            $result[] = [
310                'slug' => (string) $types->get($blueprint, 'handle'),
311                'name' => (string) $types->get($blueprint, 'label'),
312            ];
313        }
314
315        return $result;
316    }
317
318    private function intParam(
319        string $key,
320        int $default,
321        int $min,
322        ?int $max = null,
323    ): int {
324        $value = $this->request->param($key, (string) $default);
325
326        if (is_int($value)) {
327            $int = $value;
328        } elseif (is_string($value) && preg_match('/^-?[0-9]+$/', $value)) {
329            $int = (int) $value;
330        } else {
331            throw new HttpBadRequest($this->request);
332        }
333
334        if ($int < $min) {
335            throw new HttpBadRequest($this->request);
336        }
337
338        if ($max !== null && $int > $max) {
339            throw new HttpBadRequest($this->request);
340        }
341
342        return $int;
343    }
344
345    /** @return list<string> */
346    private function openParam(string $key): array
347    {
348        $value = $this->request->param($key, '');
349
350        if (!is_string($value)) {
351            throw new HttpBadRequest($this->request);
352        }
353
354        $open = [];
355
356        foreach (explode(',', $value) as $uid) {
357            $uid = trim($uid);
358
359            if ($uid !== '' && !in_array($uid, $open, true)) {
360                $open[] = $uid;
361            }
362        }
363
364        return $open;
365    }
366
367    private function stringParam(string $key): string
368    {
369        $value = $this->request->param($key, '');
370
371        if (!is_string($value)) {
372            throw new HttpBadRequest($this->request);
373        }
374
375        return trim($value);
376    }
377
378    /** @param array<array-key, mixed> $form */
379    private function formString(array $form, string $key): string
380    {
381        $value = $form[$key] ?? '';
382
383        return is_string($value) ? trim($value) : '';
384    }
385}