Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.42% covered (success)
94.42%
203 / 215
58.33% covered (warning)
58.33%
7 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Bulk
94.42% covered (success)
94.42%
203 / 215
58.33% covered (warning)
58.33%
7 / 12
59.61
0.00% covered (danger)
0.00%
0 / 1
 publish
100.00% covered (success)
100.00%
53 / 53
100.00% covered (success)
100.00%
1 / 1
9
 delete
93.88% covered (success)
93.88%
46 / 49
0.00% covered (danger)
0.00%
0 / 1
6.01
 duplicate
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
4
 coveredBySelection
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
6.03
 transaction
54.55% covered (warning)
54.55%
6 / 11
0.00% covered (danger)
0.00%
0 / 1
7.35
 selection
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
9.02
 childrenFirst
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
5
 redirect
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
8
 collection
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 actor
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
4.12
 types
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 navigation
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Controller\Panel;
6
7use Celema\Core\Exception\HttpBadRequest;
8use Celema\Core\Exception\HttpConflict;
9use Celema\Core\Exception\HttpNotFound;
10use Celema\Core\Factory\Factory;
11use Celema\Core\Request;
12use Celema\Core\Response;
13use Celema\Wire\Creator;
14use Cosray\Actor;
15use Cosray\Cms;
16use Cosray\Collection as CmsCollection;
17use Cosray\Context;
18use Cosray\Exception\RuntimeException;
19use Cosray\Navigation;
20use Cosray\Node\Duplicator;
21use Cosray\Node\PathManager;
22use Cosray\Node\Store;
23use Cosray\Node\Types;
24use Cosray\Node\Wrapper;
25use Throwable;
26
27/**
28 * Bulk operations on a collection listing selection. Every action runs in
29 * one transaction, skips nodes it must not touch, and redirects back to
30 * the listing with a `notice` summary the collection page renders.
31 */
32final class Bulk extends Panel
33{
34    private const int MAX_NODES = 250;
35
36    public function publish(Context $context, Factory $factory, string $collection): Response
37    {
38        $obj = $this->collection($collection);
39        $form = $this->formData();
40        $state = $form['state'] ?? null;
41
42        if (!in_array($state, ['published', 'draft'], true)) {
43            throw new HttpBadRequest($this->request);
44        }
45
46        $published = $state === 'published';
47        $withChildren = ($form['children'] ?? null) === '1';
48        [$nodes, $missing] = $this->selection($obj, $form);
49        $editor = $this->actor()->id;
50        $changed = 0;
51        $skippedLocked = 0;
52
53        $this->transaction($context, static function () use (
54            $context,
55            $nodes,
56            $published,
57            $withChildren,
58            $editor,
59            &$changed,
60            &$skippedLocked,
61        ): void {
62            $processed = [];
63
64            foreach ($nodes as $uid => $node) {
65                $queue = [['uid' => $uid, 'locked' => (bool) $node->meta->locked]];
66
67                while ($queue !== []) {
68                    $entry = array_shift($queue);
69
70                    // Overlapping selections and subtrees flip a node once.
71                    if (in_array($entry['uid'], $processed, true)) {
72                        continue;
73                    }
74
75                    $processed[] = $entry['uid'];
76
77                    if ($entry['locked']) {
78                        // Locked guards only the node itself; the walk goes
79                        // on below it.
80                        $skippedLocked++;
81                    } else {
82                        $context
83                            ->db
84                            ->nodes
85                            ->setPublished([
86                                'uid' => $entry['uid'],
87                                'published' => $published,
88                                'editor' => $editor,
89                            ])
90                            ->run();
91                        $changed++;
92                    }
93
94                    if (!$withChildren) {
95                        continue;
96                    }
97
98                    foreach ($context->db->nodes->childUids(['uid' => $entry['uid']])->all() as $row) {
99                        $queue[] = [
100                            'uid' => (string) $row['uid'],
101                            'locked' => (bool) $row['locked'],
102                        ];
103                    }
104                }
105            }
106        });
107
108        return $this->redirect($factory, $collection, [
109            $published ? 'published' : 'drafted' => $changed,
110            'skipped-locked' => $skippedLocked,
111            'skipped' => $missing,
112        ]);
113    }
114
115    public function delete(Context $context, Cms $cms, Factory $factory, string $collection): Response
116    {
117        $obj = $this->collection($collection);
118        $form = $this->formData();
119        $withChildren = ($form['children'] ?? null) === '1';
120        [$nodes, $missing] = $this->selection($obj, $form);
121        $store = new Store(
122            $context->db,
123            new PathManager(),
124            $this->types(),
125            $cms->nodeFactory()->uid(),
126            factory: $cms->nodeFactory(),
127            cms: $cms,
128            context: $context,
129        );
130        $actor = $this->actor();
131        $deleted = [];
132        $skippedChildren = 0;
133        $skippedLocked = 0;
134        $skipped = $missing;
135
136        $this->transaction($context, function () use (
137            $store,
138            $actor,
139            $nodes,
140            $withChildren,
141            &$deleted,
142            &$skippedChildren,
143            &$skippedLocked,
144            &$skipped,
145        ): void {
146            foreach ($this->childrenFirst($nodes) as $uid => $node) {
147                // Already gone with an earlier subtree delete.
148                if (in_array($uid, $deleted, true)) {
149                    continue;
150                }
151
152                if ($node->meta->locked) {
153                    $skippedLocked++;
154
155                    continue;
156                }
157
158                $nodeObj = Wrapper::unwrap($node);
159
160                if (!(bool) $this->types()->get($nodeObj::class, 'deletable', true)) {
161                    $skipped++;
162
163                    continue;
164                }
165
166                try {
167                    $result = $store->delete($nodeObj, $actor, $withChildren);
168                    $deleted = [...$deleted, ...$result['deleted']];
169                } catch (HttpConflict) {
170                    // The guard refuses before any SQL runs, so the
171                    // transaction stays healthy and the batch goes on.
172                    $skippedChildren++;
173                }
174            }
175        });
176
177        return $this->redirect($factory, $collection, [
178            'deleted' => count($deleted),
179            'skipped-children' => $skippedChildren,
180            'skipped-locked' => $skippedLocked,
181            'skipped' => $skipped,
182        ]);
183    }
184
185    public function duplicate(Context $context, Cms $cms, Factory $factory, string $collection): Response
186    {
187        $obj = $this->collection($collection);
188        $form = $this->formData();
189        $withChildren = ($form['children'] ?? null) === '1';
190        [$nodes, $missing] = $this->selection($obj, $form);
191        $duplicator = new Duplicator($context, $cms, $this->types());
192        $actor = $this->actor();
193        $duplicated = 0;
194
195        $this->transaction($context, function () use (
196            $cms,
197            $duplicator,
198            $actor,
199            $nodes,
200            $withChildren,
201            &$duplicated,
202        ): void {
203            foreach ($nodes as $node) {
204                // The subtree copy of a selected ancestor covers this node.
205                if ($withChildren && $this->coveredBySelection($cms, $node, $nodes)) {
206                    continue;
207                }
208
209                $result = $duplicator->duplicate($node, $actor, $withChildren);
210                $duplicated += count($result['created']);
211            }
212        });
213
214        return $this->redirect($factory, $collection, [
215            'duplicated' => $duplicated,
216            'skipped' => $missing,
217        ]);
218    }
219
220    /**
221     * Whether one of the node's ancestors is part of the selection. The
222     * chain may run through unselected nodes, so it walks the stored
223     * parents rather than just the selection.
224     *
225     * @param array<string, Wrapper> $selection
226     */
227    private function coveredBySelection(Cms $cms, Wrapper $node, array $selection): bool
228    {
229        $seen = [];
230        $parent = $node->meta->get('parent');
231
232        while (is_string($parent) && $parent !== '' && !in_array($parent, $seen, true)) {
233            if (isset($selection[$parent])) {
234                return true;
235            }
236
237            $seen[] = $parent;
238            $ancestor = $cms->node->byUid($parent, published: null);
239
240            if (!$ancestor) {
241                break;
242            }
243
244            $parent = $ancestor->meta->get('parent');
245        }
246
247        return false;
248    }
249
250    private function transaction(Context $context, callable $work): void
251    {
252        $db = $context->db;
253        $ownsTransaction = !$db->getConn()->inTransaction();
254
255        if ($ownsTransaction) {
256            $db->begin();
257        }
258
259        try {
260            $work();
261
262            if ($ownsTransaction) {
263                $db->commit();
264            }
265        } catch (Throwable $e) {
266            if ($ownsTransaction) {
267                $db->rollback();
268            }
269
270            throw $e;
271        }
272    }
273
274    /**
275     * The submitted uids resolved through the collection's own finder, so
276     * only nodes the listing actually shows are operable. Returns the
277     * found wrappers keyed by uid plus the count of uids that did not
278     * resolve (unknown, deleted, or outside the collection).
279     *
280     * @param array<array-key, mixed> $form
281     * @return array{0: array<string, Wrapper>, 1: int}
282     */
283    private function selection(CmsCollection $obj, array $form): array
284    {
285        $submitted = $form['nodes'] ?? null;
286
287        if (!is_array($submitted)) {
288            throw new HttpBadRequest($this->request);
289        }
290
291        $uids = [];
292
293        foreach ($submitted as $uid) {
294            if (!is_string($uid)) {
295                throw new HttpBadRequest($this->request);
296            }
297
298            $uid = trim($uid);
299
300            if ($uid !== '' && !in_array($uid, $uids, true)) {
301                $uids[] = $uid;
302            }
303        }
304
305        if ($uids === [] || count($uids) > self::MAX_NODES) {
306            throw new HttpBadRequest($this->request);
307        }
308
309        $nodes = [];
310
311        foreach ($obj->entries()->only(...$uids) as $node) {
312            $nodes[$node->meta->uid] = $node;
313        }
314
315        return [$nodes, count($uids) - count($nodes)];
316    }
317
318    /**
319     * Selected children before selected parents, so deleting a branch that
320     * was selected row by row needs no subtree flag and no second attempt.
321     *
322     * @param array<string, Wrapper> $nodes
323     * @return array<string, Wrapper>
324     */
325    private function childrenFirst(array $nodes): array
326    {
327        $depths = [];
328
329        foreach ($nodes as $uid => $node) {
330            $depth = 0;
331            $current = $node;
332
333            while ($depth <= count($nodes)) {
334                $parent = $current->meta->get('parent');
335
336                if (!is_string($parent) || !isset($nodes[$parent])) {
337                    break;
338                }
339
340                $current = $nodes[$parent];
341                $depth++;
342            }
343
344            $depths[$uid] = $depth;
345        }
346
347        uksort(
348            $nodes,
349            static fn(string $a, string $b): int => $depths[$b] <=> $depths[$a],
350        );
351
352        return $nodes;
353    }
354
355    /** @param array<string, int> $counts */
356    private function redirect(Factory $factory, string $collection, array $counts): Response
357    {
358        $notice = [];
359
360        foreach ($counts as $key => $count) {
361            if ($count > 0) {
362                $notice[] = $key . ':' . $count;
363            }
364        }
365
366        // Reflect the listing query the bulk URL carried back into the
367        // redirect, so the user lands on the view they acted in.
368        $params = [];
369
370        foreach (['q', 'sort', 'dir', 'offset', 'limit', 'parent', 'view', 'open'] as $key) {
371            $value = $this->request->param($key, '');
372
373            if (is_string($value) && trim($value) !== '') {
374                $params[$key] = trim($value);
375            }
376        }
377
378        if ($notice !== []) {
379            $params['notice'] = implode(',', $notice);
380        }
381
382        $path = $this->panelPath() . '/collection/' . rawurlencode($collection);
383        $query = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
384
385        return Response::create($factory)->redirect(
386            $query === '' ? $path : $path . '?' . $query,
387            303,
388        );
389    }
390
391    private function collection(string $collection): CmsCollection
392    {
393        try {
394            $ref = $this->navigation()->ref($collection);
395        } catch (RuntimeException $e) {
396            throw new HttpNotFound($this->request, previous: $e);
397        }
398
399        $creator = new Creator($this->container);
400        $obj = $creator->create(
401            $ref->class,
402            predefinedTypes: [Request::class => $this->request],
403        );
404        assert($obj instanceof CmsCollection, 'The bulk route must resolve a collection');
405
406        return $obj;
407    }
408
409    private function actor(): Actor
410    {
411        try {
412            $id = $this->request->get('session')->authenticatedUserId();
413        } catch (Throwable) {
414            $id = null;
415        }
416
417        return $id ? new Actor((int) $id) : Actor::system();
418    }
419
420    private function types(): Types
421    {
422        $types = $this->container->get(Types::class);
423        assert($types instanceof Types, 'The node type service must be available');
424
425        return $types;
426    }
427
428    private function navigation(): Navigation
429    {
430        $navigation = $this->container->get(Navigation::class);
431        assert($navigation instanceof Navigation, 'The navigation service must be available');
432
433        return $navigation;
434    }
435}