Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.04% covered (warning)
88.04%
81 / 92
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Duplicator
88.04% covered (warning)
88.04%
81 / 92
50.00% covered (danger)
50.00%
4 / 8
30.44
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 duplicate
60.00% covered (warning)
60.00%
9 / 15
0.00% covered (danger)
0.00%
0 / 1
2.26
 copyTree
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
8.01
 copy
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
 markCopy
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
7.54
 marker
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
5.09
 childUids
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 emptyPaths
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Node;
6
7use Celema\Verba\Translator;
8use Celema\Verba\Verba;
9use Cosray\Actor;
10use Cosray\Cms;
11use Cosray\Context;
12use Cosray\Exception\RuntimeException;
13use Cosray\Field\Field;
14use Cosray\Title\Resolver as TitleResolver;
15use Cosray\Util\Transaction;
16use Throwable;
17
18/**
19 * Copies a node — with children, its whole subtree — through the regular
20 * create pipeline, so validation, reference indexing, title
21 * materialization, and path generation all apply to the copies.
22 */
23final class Duplicator
24{
25    private readonly Factory $factory;
26    private readonly Serializer $serializer;
27    private readonly Store $store;
28    private readonly TitleResolver $titles;
29
30    /** @var array<string, string> Copy marker per content locale, lazily translated. */
31    private array $markers = [];
32
33    public function __construct(
34        private readonly Context $context,
35        private readonly Cms $cms,
36        Types $types,
37    ) {
38        $this->titles = new TitleResolver($types);
39        $this->factory = $cms->nodeFactory();
40        $this->serializer = new Serializer($types, $this->factory->uid());
41        $this->store = new Store(
42            $context->db,
43            new PathManager(ReservedPaths::fromConfig($context->config)),
44            $types,
45            $this->factory->uid(),
46            factory: $this->factory,
47            cms: $cms,
48            context: $context,
49        );
50    }
51
52    /**
53     * Every copy starts as an unlocked draft with a fresh uid and no
54     * handle. Children are created after their copied parent, so their
55     * generated routes compose under the copy's actual paths.
56     *
57     * @return array{success: true, created: list<string>}
58     */
59    public function duplicate(Wrapper $node, Actor $actor, bool $withChildren = false): array
60    {
61        try {
62            $created = Transaction::run($this->context->db, fn(): array => $this->copyTree(
63                $node,
64                $actor,
65                $withChildren,
66            ));
67        } catch (Throwable $e) {
68            throw new RuntimeException(
69                'Error while duplicating: ' . $e->getMessage(),
70                (int) $e->getCode(),
71                previous: $e,
72            );
73        }
74
75        return [
76            'success' => true,
77            'created' => $created,
78        ];
79    }
80
81    /**
82     * Copies the node, and with children its subtree, breadth first.
83     *
84     * @return list<string> The uids of the copies
85     */
86    private function copyTree(Wrapper $node, Actor $actor, bool $withChildren): array
87    {
88        $created = [];
89        $parent = $node->meta->get('parent');
90        $copied = [$node->meta->uid];
91        // Only the subtree root gets the copy marker: it is the entry
92        // the user looks for in the listing afterwards.
93        $queue = [[$node, is_string($parent) && trim($parent) !== '' ? $parent : null, true]];
94
95        while ($queue !== []) {
96            [$current, $parentUid, $mark] = array_shift($queue);
97            $copyUid = $this->copy($current, $parentUid, $actor, $mark);
98            $created[] = $copyUid;
99
100            if (!$withChildren) {
101                break;
102            }
103
104            foreach ($this->childUids($current->meta->uid) as $childUid) {
105                // A parent cycle would queue forever; unseen sources only.
106                if (in_array($childUid, $copied, true)) {
107                    continue;
108                }
109
110                $child = $this->cms->node->byUid($childUid, published: null);
111
112                if ($child) {
113                    $copied[] = $childUid;
114                    $queue[] = [$child, $copyUid, false];
115                }
116            }
117        }
118
119        return $created;
120    }
121
122    private function copy(Wrapper $wrapper, ?string $parentUid, Actor $actor, bool $mark): string
123    {
124        $source = Wrapper::unwrap($wrapper);
125        $data = $this->serializer->read(
126            $source,
127            Factory::dataFor($source),
128            Factory::fieldNamesFor($source),
129        );
130
131        // No uid key: the store generates a fresh one with its own retry.
132        unset($data['uid']);
133        $data['parent'] = $parentUid;
134        $data['published'] = false;
135        // The store refuses locked payloads, and a locked copy could not
136        // be edited afterwards.
137        $data['locked'] = false;
138        // Handles are unique identities, never copied.
139        $data['handle'] = null;
140        // Empty paths force regeneration under the copy's parent;
141        // PathManager suffixes any collision with the source's paths.
142        $data['paths'] = $this->emptyPaths();
143
144        if ($mark && is_array($data['content'] ?? null)) {
145            $data['content'] = $this->markCopy($source::class, $data['content']);
146        }
147
148        // A blueprint object instead of the source node: the store falls
149        // back to node meta for absent data keys (uid, parent, handle),
150        // and the source's must not leak into the copy.
151        $blueprint = $this->factory->blueprint($source::class, $this->context, $this->cms);
152        $result = $this->store->create($blueprint, $data, $this->context->locales(), $actor);
153
154        return $result['uid'];
155    }
156
157    /**
158     * Appends the localized copy marker to every non-empty locale value of
159     * the title field, so the copy is identifiable in the listing.
160     * Types without a writable title field stay unmarked.
161     *
162     * @param class-string $class
163     * @param array<string, mixed> $content
164     * @return array<string, mixed>
165     */
166    private function markCopy(string $class, array $content): array
167    {
168        $field = $this->titles->writableField($class);
169        $value = $field === null ? null : $content[$field]['value'] ?? null;
170
171        if ($field === null || !is_array($value)) {
172            return $content;
173        }
174
175        foreach ($value as $locale => $text) {
176            if (!is_string($text) || trim($text) === '') {
177                continue;
178            }
179
180            $content[$field]['value'][$locale] = $text . ' ' . $this->marker((string) $locale);
181        }
182
183        return $content;
184    }
185
186    /**
187     * The copy marker translated into a content locale (the default locale
188     * for the neutral key), resolved through a briefly activated
189     * per-locale translator so the scanner sees the message id.
190     */
191    private function marker(string $localeId): string
192    {
193        if (array_key_exists($localeId, $this->markers)) {
194            return $this->markers[$localeId];
195        }
196
197        $locales = $this->context->locales();
198        $locale = $localeId === Field::NEUTRAL_LOCALE || !$locales->exists($localeId)
199            ? $locales->getDefault()
200            : $locales->get($localeId);
201        $previous = Verba::translator();
202        Verba::activate(new Translator($locale->id, $locales->catalogs(), $locale->fallbacks()));
203
204        try {
205            $marker = __('node:copy-suffix');
206        } finally {
207            if ($previous) {
208                Verba::activate($previous);
209            } else {
210                Verba::deactivate();
211            }
212        }
213
214        return $this->markers[$localeId] = $marker;
215    }
216
217    /** @return list<string> */
218    private function childUids(string $uid): array
219    {
220        return array_map(
221            static fn(array $row): string => (string) $row['uid'],
222            $this->context->db->nodes->childUids(['uid' => $uid])->all(),
223        );
224    }
225
226    /** @return array<string, string> */
227    private function emptyPaths(): array
228    {
229        $paths = [];
230
231        foreach ($this->context->locales() as $locale) {
232            $paths[$locale->id] = '';
233        }
234
235        return $paths;
236    }
237}