Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.97% covered (success)
97.97%
193 / 197
70.00% covered (warning)
70.00%
7 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Bulk
97.97% covered (success)
97.97%
193 / 197
70.00% covered (warning)
70.00%
7 / 10
53
0.00% covered (danger)
0.00%
0 / 1
 publish
100.00% covered (success)
100.00%
70 / 70
100.00% covered (success)
100.00%
1 / 1
15
 delete
95.35% covered (success)
95.35%
41 / 43
0.00% covered (danger)
0.00%
0 / 1
7
 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
 store
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 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%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 collection
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 types
100.00% covered (success)
100.00%
1 / 1
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\Factory\Factory;
10use Celema\Core\Response;
11use Cosray\Cms;
12use Cosray\Collection\Listing;
13use Cosray\Context;
14use Cosray\Exception\NodeLocked;
15use Cosray\Node\Duplicator;
16use Cosray\Node\PathManager;
17use Cosray\Node\ReservedPaths;
18use Cosray\Node\Store;
19use Cosray\Node\Types;
20use Cosray\Node\Wrapper;
21use Cosray\Util\Transaction;
22
23/**
24 * Bulk operations on a collection listing selection. Every action runs in
25 * one transaction, skips nodes it must not touch, and redirects back to
26 * the listing with a `notice` summary the collection page renders.
27 */
28final class Bulk extends Panel
29{
30    private const int MAX_NODES = 250;
31
32    public function publish(Context $context, Cms $cms, Factory $factory, string $collection): Response
33    {
34        $obj = $this->collection($collection);
35        $form = $this->formData();
36        $state = $form['state'] ?? null;
37
38        if (!in_array($state, ['published', 'unpublished'], true)) {
39            throw new HttpBadRequest($this->request);
40        }
41
42        $published = $state === 'published';
43        $withChanges = $published && ($form['changes'] ?? null) === '1';
44        $withChildren = ($form['children'] ?? null) === '1';
45        [$nodes, $missing] = $this->selection($obj, $form);
46        $store = $this->store($context, $cms);
47        $locales = $context->locales();
48        $actor = $this->actor();
49        $changed = 0;
50        $changesPublished = 0;
51        $skippedLocked = 0;
52
53        Transaction::run($context->db, static function () use (
54            $context,
55            $cms,
56            $store,
57            $locales,
58            $actor,
59            $nodes,
60            $published,
61            $withChanges,
62            $withChildren,
63            &$changed,
64            &$changesPublished,
65            &$skippedLocked,
66        ): void {
67            $processed = [];
68
69            foreach ($nodes as $uid => $node) {
70                $queue = [['uid' => $uid, 'locked' => (bool) $node->meta->locked]];
71
72                while ($queue !== []) {
73                    $entry = array_shift($queue);
74
75                    // Overlapping selections and subtrees flip a node once.
76                    if (in_array($entry['uid'], $processed, true)) {
77                        continue;
78                    }
79
80                    $processed[] = $entry['uid'];
81
82                    if ($entry['locked']) {
83                        // Locked guards only the node itself; the walk goes
84                        // on below it.
85                        $skippedLocked++;
86                    } elseif (($current = $cms->node->byUid($entry['uid'], published: null)) !== null) {
87                        $nodeObj = Wrapper::unwrap($current);
88
89                        if (!$published) {
90                            $store->unpublish($nodeObj, $locales, $actor);
91                        } elseif (!$current->meta->published) {
92                            $context
93                                ->db
94                                ->nodes
95                                ->setPublished([
96                                    'uid' => $entry['uid'],
97                                    'published' => true,
98                                    'editor' => $actor->id,
99                                ])
100                                ->run();
101                        } elseif ($withChanges && $store->publishDraft($nodeObj, $locales, $actor)) {
102                            $changesPublished++;
103                        }
104
105                        $changed++;
106                    }
107
108                    if (!$withChildren) {
109                        continue;
110                    }
111
112                    foreach ($context->db->nodes->childUids(['uid' => $entry['uid']])->all() as $row) {
113                        $queue[] = [
114                            'uid' => (string) $row['uid'],
115                            'locked' => (bool) $row['locked'],
116                        ];
117                    }
118                }
119            }
120        });
121
122        return $this->redirect($factory, $collection, [
123            $published ? 'published' : 'unpublished' => $changed,
124            'changes-published' => $changesPublished,
125            'skipped-locked' => $skippedLocked,
126            'skipped' => $missing,
127        ]);
128    }
129
130    public function delete(Context $context, Cms $cms, Factory $factory, string $collection): Response
131    {
132        $obj = $this->collection($collection);
133        $form = $this->formData();
134        $withChildren = ($form['children'] ?? null) === '1';
135        [$nodes, $missing] = $this->selection($obj, $form);
136        $store = $this->store($context, $cms);
137        $actor = $this->actor();
138        $deleted = [];
139        $skippedChildren = 0;
140        $skippedLocked = 0;
141        $skipped = $missing;
142
143        Transaction::run($context->db, function () use (
144            $store,
145            $actor,
146            $nodes,
147            $withChildren,
148            &$deleted,
149            &$skippedChildren,
150            &$skippedLocked,
151            &$skipped,
152        ): void {
153            foreach ($this->childrenFirst($nodes) as $uid => $node) {
154                // Already gone with an earlier subtree delete.
155                if (in_array($uid, $deleted, true)) {
156                    continue;
157                }
158
159                if ($node->meta->locked) {
160                    $skippedLocked++;
161
162                    continue;
163                }
164
165                $nodeObj = Wrapper::unwrap($node);
166
167                if (!(bool) $this->types()->get($nodeObj::class, 'deletable', true)) {
168                    $skipped++;
169
170                    continue;
171                }
172
173                try {
174                    $result = $store->delete($nodeObj, $actor, $withChildren);
175                    $deleted = [...$deleted, ...$result['deleted']];
176                } catch (NodeLocked) {
177                    $skippedLocked++;
178                } catch (HttpConflict) {
179                    // The guard refuses before any SQL runs, so the
180                    // transaction stays healthy and the batch goes on.
181                    $skippedChildren++;
182                }
183            }
184        });
185
186        return $this->redirect($factory, $collection, [
187            'deleted' => count($deleted),
188            'skipped-children' => $skippedChildren,
189            'skipped-locked' => $skippedLocked,
190            'skipped' => $skipped,
191        ]);
192    }
193
194    public function duplicate(Context $context, Cms $cms, Factory $factory, string $collection): Response
195    {
196        $obj = $this->collection($collection);
197        $form = $this->formData();
198        $withChildren = ($form['children'] ?? null) === '1';
199        [$nodes, $missing] = $this->selection($obj, $form);
200        $duplicator = new Duplicator($context, $cms, $this->types());
201        $actor = $this->actor();
202        $duplicated = 0;
203
204        Transaction::run($context->db, function () use (
205            $cms,
206            $duplicator,
207            $actor,
208            $nodes,
209            $withChildren,
210            &$duplicated,
211        ): void {
212            foreach ($nodes as $node) {
213                // The subtree copy of a selected ancestor covers this node.
214                if ($withChildren && $this->coveredBySelection($cms, $node, $nodes)) {
215                    continue;
216                }
217
218                $result = $duplicator->duplicate($node, $actor, $withChildren);
219                $duplicated += count($result['created']);
220            }
221        });
222
223        return $this->redirect($factory, $collection, [
224            'duplicated' => $duplicated,
225            'skipped' => $missing,
226        ]);
227    }
228
229    /**
230     * Whether one of the node's ancestors is part of the selection. The
231     * chain may run through unselected nodes, so it walks the stored
232     * parents rather than just the selection.
233     *
234     * @param array<string, Wrapper> $selection
235     */
236    private function coveredBySelection(Cms $cms, Wrapper $node, array $selection): bool
237    {
238        $seen = [];
239        $parent = $node->meta->get('parent');
240
241        while (is_string($parent) && $parent !== '' && !in_array($parent, $seen, true)) {
242            if (isset($selection[$parent])) {
243                return true;
244            }
245
246            $seen[] = $parent;
247            $ancestor = $cms->node->byUid($parent, published: null);
248
249            if (!$ancestor) {
250                break;
251            }
252
253            $parent = $ancestor->meta->get('parent');
254        }
255
256        return false;
257    }
258
259    private function store(Context $context, Cms $cms): Store
260    {
261        return new Store(
262            $context->db,
263            new PathManager(ReservedPaths::fromConfig($context->config)),
264            $this->types(),
265            $cms->nodeFactory()->uid(),
266            factory: $cms->nodeFactory(),
267            cms: $cms,
268            context: $context,
269        );
270    }
271
272    /**
273     * The submitted uids resolved through the collection's own finder, so
274     * only nodes the listing actually shows are operable. Returns the
275     * found wrappers keyed by uid plus the count of uids that did not
276     * resolve (unknown, deleted, or outside the collection).
277     *
278     * @param array<array-key, mixed> $form
279     * @return array{0: array<string, Wrapper>, 1: int}
280     */
281    private function selection(Listing $obj, array $form): array
282    {
283        $submitted = $form['nodes'] ?? null;
284
285        if (!is_array($submitted)) {
286            throw new HttpBadRequest($this->request);
287        }
288
289        $uids = [];
290
291        foreach ($submitted as $uid) {
292            if (!is_string($uid)) {
293                throw new HttpBadRequest($this->request);
294            }
295
296            $uid = trim($uid);
297
298            if ($uid !== '' && !in_array($uid, $uids, true)) {
299                $uids[] = $uid;
300            }
301        }
302
303        if ($uids === [] || count($uids) > self::MAX_NODES) {
304            throw new HttpBadRequest($this->request);
305        }
306
307        $nodes = [];
308
309        foreach ($obj->entries()->only(...$uids) as $node) {
310            $nodes[$node->meta->uid] = $node;
311        }
312
313        return [$nodes, count($uids) - count($nodes)];
314    }
315
316    /**
317     * Selected children before selected parents, so deleting a branch that
318     * was selected row by row needs no subtree flag and no second attempt.
319     *
320     * @param array<string, Wrapper> $nodes
321     * @return array<string, Wrapper>
322     */
323    private function childrenFirst(array $nodes): array
324    {
325        $depths = [];
326
327        foreach ($nodes as $uid => $node) {
328            $depth = 0;
329            $current = $node;
330
331            while ($depth <= count($nodes)) {
332                $parent = $current->meta->get('parent');
333
334                if (!is_string($parent) || !isset($nodes[$parent])) {
335                    break;
336                }
337
338                $current = $nodes[$parent];
339                $depth++;
340            }
341
342            $depths[$uid] = $depth;
343        }
344
345        uksort(
346            $nodes,
347            static fn(string $a, string $b): int => $depths[$b] <=> $depths[$a],
348        );
349
350        return $nodes;
351    }
352
353    /** @param array<string, int> $counts */
354    private function redirect(Factory $factory, string $collection, array $counts): Response
355    {
356        $notice = [];
357
358        foreach ($counts as $key => $count) {
359            if ($count > 0) {
360                $notice[] = $key . ':' . $count;
361            }
362        }
363
364        return $this->listingRedirect($factory, $collection, $notice === [] ? [] : ['notice' => implode(',', $notice)]);
365    }
366
367    private function collection(string $collection): Listing
368    {
369        return $this->listing($this->ref($collection));
370    }
371
372    private function types(): Types
373    {
374        return $this->container->instance(Types::class);
375    }
376}