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