• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Yoast / duplicate-post / 14725616456

29 Apr 2025 07:21AM UTC coverage: 45.469% (-4.7%) from 50.122%
14725616456

push

github

web-flow
Merge pull request #402 from Yoast/feature/drop-php-7.2-7.3

Drop support for Php 7.2 and 7.3

1164 of 2560 relevant lines covered (45.47%)

1.61 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

59.09
/src/post-duplicator.php
1
<?php
2

3
namespace Yoast\WP\Duplicate_Post;
4

5
use WP_Error;
6
use WP_Post;
7

8
/**
9
 * Duplicate Post class to create copies.
10
 *
11
 * @since 4.0
12
 */
13
class Post_Duplicator {
14

15
        /**
16
         * Returns an array with the default option values.
17
         *
18
         * @return array<string, bool|int|string|array|null> The default options values.
19
         */
20
        public function get_default_options() {
6✔
21
                return [
6✔
22
                        'copy_title'             => true,
6✔
23
                        'copy_date'              => false,
6✔
24
                        'copy_status'            => false,
6✔
25
                        'copy_name'              => false,
6✔
26
                        'copy_excerpt'           => true,
6✔
27
                        'copy_content'           => true,
6✔
28
                        'copy_thumbnail'         => true,
6✔
29
                        'copy_template'          => true,
6✔
30
                        'copy_format'            => true,
6✔
31
                        'copy_author'            => false,
6✔
32
                        'copy_password'          => false,
6✔
33
                        'copy_attachments'       => false,
6✔
34
                        'copy_children'          => false,
6✔
35
                        'copy_comments'          => false,
6✔
36
                        'copy_menu_order'        => true,
6✔
37
                        'title_prefix'           => '',
6✔
38
                        'title_suffix'           => '',
6✔
39
                        'increase_menu_order_by' => null,
6✔
40
                        'parent_id'              => null,
6✔
41
                        'meta_excludelist'       => [],
6✔
42
                        'taxonomies_excludelist' => [],
6✔
43
                        'use_filters'            => true,
6✔
44
                ];
6✔
45
        }
46

47
        /**
48
         * Creates a copy of a post object, accordingly to an options array.
49
         *
50
         * @param WP_Post $post    The original post object.
51
         * @param array   $options The options overriding the default ones.
52
         *
53
         * @return int|WP_Error The copy ID, or a WP_Error object on failure.
54
         */
55
        public function create_duplicate( WP_Post $post, array $options = [] ) {
4✔
56
                $defaults = $this->get_default_options();
4✔
57
                $options  = \wp_parse_args( $options, $defaults );
4✔
58

59
                $title           = '';
4✔
60
                $new_post_status = $post->post_status;
4✔
61
                if ( $post->post_type !== 'attachment' ) {
4✔
62
                        $title           = $this->generate_copy_title( $post, $options );
4✔
63
                        $new_post_status = $this->generate_copy_status( $post, $options );
4✔
64
                }
65

66
                $new_post_author_id = $this->generate_copy_author( $post, $options );
4✔
67

68
                $menu_order = 0;
4✔
69
                if ( $options['copy_menu_order'] ) {
4✔
70
                        $menu_order = $post->menu_order;
4✔
71
                }
72

73
                if ( ! empty( $options['increase_menu_order_by'] ) && \is_numeric( $options['increase_menu_order_by'] ) ) {
4✔
74
                        $menu_order += \intval( $options['increase_menu_order_by'] );
×
75
                }
76

77
                $new_post = [
4✔
78
                        'post_author'           => $new_post_author_id,
4✔
79
                        'post_content'          => ( $options['copy_content'] ) ? $post->post_content : '',
4✔
80
                        'post_content_filtered' => ( $options['copy_content'] ) ? $post->post_content_filtered : '',
4✔
81
                        'post_title'            => $title,
4✔
82
                        'post_excerpt'          => ( $options['copy_excerpt'] ) ? $post->post_excerpt : '',
4✔
83
                        'post_status'           => $new_post_status,
4✔
84
                        'post_type'             => $post->post_type,
4✔
85
                        'comment_status'        => $post->comment_status,
4✔
86
                        'ping_status'           => $post->ping_status,
4✔
87
                        'post_password'         => ( $options['copy_password'] ) ? $post->post_password : '',
4✔
88
                        'post_name'             => ( $options['copy_name'] ) ? $post->post_name : '',
4✔
89
                        'post_parent'           => empty( $options['parent_id'] ) ? $post->post_parent : $options['parent_id'],
4✔
90
                        'menu_order'            => $menu_order,
4✔
91
                        'post_mime_type'        => $post->post_mime_type,
4✔
92
                ];
4✔
93

94
                if ( $options['copy_date'] ) {
4✔
95
                        $new_post_date             = $post->post_date;
4✔
96
                        $new_post['post_date']     = $new_post_date;
4✔
97
                        $new_post['post_date_gmt'] = \get_gmt_from_date( $new_post_date );
4✔
98
                        \add_filter( 'wp_insert_post_data', [ $this, 'set_modified' ], 1, 1 );
4✔
99
                }
100

101
                if ( $options['use_filters'] ) {
4✔
102
                        /**
103
                         * Filter new post values.
104
                         *
105
                         * @param array   $new_post New post values.
106
                         * @param WP_Post $post     Original post object.
107
                         *
108
                         * @return array
109
                         */
110
                        $new_post = \apply_filters( 'duplicate_post_new_post', $new_post, $post );
4✔
111
                }
112

113
                $new_post_id = \wp_insert_post( \wp_slash( $new_post ), true );
4✔
114

115
                if ( $options['copy_date'] ) {
4✔
116
                        \remove_filter( 'wp_insert_post_data', [ $this, 'set_modified' ], 1 );
4✔
117
                }
118

119
                if ( ! \is_wp_error( $new_post_id ) ) {
4✔
120
                        \delete_post_meta( $new_post_id, '_dp_original' );
4✔
121
                        \add_post_meta( $new_post_id, '_dp_original', $post->ID );
4✔
122
                }
123

124
                return $new_post_id;
4✔
125
        }
126

127
        /**
128
         * Modifies the post data to set the modified date to now.
129
         *
130
         * This is needed for the Block editor when a post is copied with its date,
131
         * so that the current publish date is shown instead of "Immediately".
132
         *
133
         * @param array $data The array of post data.
134
         *
135
         * @return array The updated array of post data.
136
         */
137
        public function set_modified( $data ) {
4✔
138
                $data['post_modified']     = \current_time( 'mysql' );
4✔
139
                $data['post_modified_gmt'] = \current_time( 'mysql', 1 );
4✔
140

141
                return $data;
4✔
142
        }
143

144
        /**
145
         * Wraps the function to create a copy for the Rewrite & Republish feature.
146
         *
147
         * @param WP_Post $post The original post object.
148
         *
149
         * @return int|WP_Error The copy ID, or a WP_Error object on failure.
150
         */
151
        public function create_duplicate_for_rewrite_and_republish( WP_Post $post ) {
×
152
                $options  = [
×
153
                        'copy_title'      => true,
×
154
                        'copy_date'       => true,
×
155
                        'copy_name'       => false,
×
156
                        'copy_content'    => true,
×
157
                        'copy_excerpt'    => true,
×
158
                        'copy_author'     => true,
×
159
                        'copy_menu_order' => true,
×
160
                        'use_filters'     => false,
×
161
                ];
×
162
                $defaults = $this->get_default_options();
×
163
                $options  = \wp_parse_args( $options, $defaults );
×
164

165
                $new_post_id = $this->create_duplicate( $post, $options );
×
166

167
                if ( ! \is_wp_error( $new_post_id ) ) {
×
168
                        $this->copy_post_taxonomies( $new_post_id, $post, $options );
×
169
                        $this->copy_post_meta_info( $new_post_id, $post, $options );
×
170

171
                        \update_post_meta( $new_post_id, '_dp_is_rewrite_republish_copy', 1 );
×
172
                        \update_post_meta( $post->ID, '_dp_has_rewrite_republish_copy', $new_post_id );
×
173
                        \update_post_meta( $new_post_id, '_dp_creation_date_gmt', \current_time( 'mysql', 1 ) );
×
174
                }
175

176
                return $new_post_id;
×
177
        }
178

179
        /**
180
         * Copies the taxonomies of a post to another post.
181
         *
182
         * @param int     $new_id  New post ID.
183
         * @param WP_Post $post    The original post object.
184
         * @param array   $options The options array.
185
         *
186
         * @return void
187
         */
188
        public function copy_post_taxonomies( $new_id, $post, $options ) {
×
189
                // Clear default category (added by wp_insert_post).
190
                \wp_set_object_terms( $new_id, [], 'category' );
×
191

192
                $post_taxonomies = \get_object_taxonomies( $post->post_type );
×
193
                // Several plugins just add support to post-formats but don't register post_format taxonomy.
194
                if ( \post_type_supports( $post->post_type, 'post-formats' ) && ! \in_array( 'post_format', $post_taxonomies, true ) ) {
×
195
                        $post_taxonomies[] = 'post_format';
×
196
                }
197

198
                $taxonomies_excludelist = $options['taxonomies_excludelist'];
×
199
                if ( ! \is_array( $taxonomies_excludelist ) ) {
×
200
                        $taxonomies_excludelist = [];
×
201
                }
202

203
                if ( ! $options['copy_format'] ) {
×
204
                        $taxonomies_excludelist[] = 'post_format';
×
205
                }
206

207
                if ( $options['use_filters'] ) {
×
208
                        /**
209
                         * Filters the taxonomy excludelist when copying a post.
210
                         *
211
                         * @param array $taxonomies_excludelist The taxonomy excludelist from the options.
212
                         *
213
                         * @return array
214
                         */
215
                        $taxonomies_excludelist = \apply_filters( 'duplicate_post_taxonomies_excludelist_filter', $taxonomies_excludelist );
×
216
                }
217

218
                $post_taxonomies = \array_diff( $post_taxonomies, $taxonomies_excludelist );
×
219

220
                foreach ( $post_taxonomies as $taxonomy ) {
×
221
                        $post_terms = \wp_get_object_terms( $post->ID, $taxonomy, [ 'orderby' => 'term_order' ] );
×
222
                        $terms      = [];
×
223
                        $num_terms  = \count( $post_terms );
×
224
                        for ( $i = 0; $i < $num_terms; $i++ ) {
×
225
                                $terms[] = $post_terms[ $i ]->slug;
×
226
                        }
227
                        \wp_set_object_terms( $new_id, $terms, $taxonomy );
×
228
                }
229
        }
230

231
        /**
232
         * Copies the meta information of a post to another post.
233
         *
234
         * @param int     $new_id  The new post ID.
235
         * @param WP_Post $post    The original post object.
236
         * @param array   $options The options array.
237
         *
238
         * @return void
239
         */
240
        public function copy_post_meta_info( $new_id, $post, $options ) {
×
241
                $post_meta_keys = \get_post_custom_keys( $post->ID );
×
242
                if ( empty( $post_meta_keys ) ) {
×
243
                        return;
×
244
                }
245
                $meta_excludelist = $options['meta_excludelist'];
×
246
                if ( ! \is_array( $meta_excludelist ) ) {
×
247
                        $meta_excludelist = [];
×
248
                }
249
                $meta_excludelist = \array_merge( $meta_excludelist, Utils::get_default_filtered_meta_names() );
×
250
                if ( ! $options['copy_template'] ) {
×
251
                        $meta_excludelist[] = '_wp_page_template';
×
252
                }
253
                if ( ! $options['copy_thumbnail'] ) {
×
254
                        $meta_excludelist[] = '_thumbnail_id';
×
255
                }
256

257
                if ( $options['use_filters'] ) {
×
258
                        /**
259
                         * Filters the meta fields excludelist when copying a post.
260
                         *
261
                         * @param array $meta_excludelist The meta fields excludelist from the options.
262
                         *
263
                         * @return array
264
                         */
265
                        $meta_excludelist = \apply_filters( 'duplicate_post_excludelist_filter', $meta_excludelist );
×
266
                }
267

268
                $meta_excludelist_string = '(' . \implode( ')|(', $meta_excludelist ) . ')';
×
269
                if ( \strpos( $meta_excludelist_string, '*' ) !== false ) {
×
270
                        $meta_excludelist_string = \str_replace( [ '*' ], [ '[a-zA-Z0-9_]*' ], $meta_excludelist_string );
×
271

272
                        $meta_keys = [];
×
273
                        foreach ( $post_meta_keys as $meta_key ) {
×
274
                                if ( ! \preg_match( '#^' . $meta_excludelist_string . '$#', $meta_key ) ) {
×
275
                                        $meta_keys[] = $meta_key;
×
276
                                }
277
                        }
278
                }
279
                else {
280
                        $meta_keys = \array_diff( $post_meta_keys, $meta_excludelist );
×
281
                }
282

283
                if ( $options['use_filters'] ) {
×
284
                        /**
285
                         * Filters the list of meta fields names when copying a post.
286
                         *
287
                         * @param array $meta_keys The list of meta fields name, with the ones in the excludelist already removed.
288
                         *
289
                         * @return array
290
                         */
291
                        $meta_keys = \apply_filters( 'duplicate_post_meta_keys_filter', $meta_keys );
×
292
                }
293

294
                foreach ( $meta_keys as $meta_key ) {
×
295
                        $meta_values = \get_post_custom_values( $meta_key, $post->ID );
×
296

297
                        // Clear existing meta data so that add_post_meta() works properly with non-unique keys.
298
                        \delete_post_meta( $new_id, $meta_key );
×
299

300
                        foreach ( $meta_values as $meta_value ) {
×
301
                                $meta_value = \maybe_unserialize( $meta_value );
×
302
                                \add_post_meta( $new_id, $meta_key, Utils::recursively_slash_strings( $meta_value ) );
×
303
                        }
304
                }
305
        }
306

307
        /**
308
         * Generates and returns the title for the copy.
309
         *
310
         * @param WP_Post $post    The original post object.
311
         * @param array   $options The options array.
312
         *
313
         * @return string The calculated title for the copy.
314
         */
315
        public function generate_copy_title( WP_Post $post, array $options ) {
16✔
316
                $prefix = \sanitize_text_field( $options['title_prefix'] );
16✔
317
                $suffix = \sanitize_text_field( $options['title_suffix'] );
16✔
318
                if ( $options['copy_title'] ) {
16✔
319
                        $title = $post->post_title;
12✔
320
                        if ( ! empty( $prefix ) ) {
12✔
321
                                $prefix .= ' ';
4✔
322
                        }
323
                        if ( ! empty( $suffix ) ) {
12✔
324
                                $suffix = ' ' . $suffix;
8✔
325
                        }
326
                }
327
                else {
328
                        $title = '';
4✔
329
                }
330
                return \trim( $prefix . $title . $suffix );
16✔
331
        }
332

333
        /**
334
         * Generates and returns the status for the copy.
335
         *
336
         * @param WP_Post $post    The original post object.
337
         * @param array   $options The options array.
338
         *
339
         * @return string The calculated status for the copy.
340
         */
341
        public function generate_copy_status( WP_Post $post, array $options ) {
14✔
342
                $new_post_status = 'draft';
14✔
343

344
                if ( $options['copy_status'] ) {
14✔
345
                        $new_post_status = $post->post_status;
8✔
346
                        if ( $new_post_status === 'publish' || $new_post_status === 'future' ) {
8✔
347
                                // Check if the user has the right capability.
348
                                if ( \is_post_type_hierarchical( $post->post_type ) ) {
6✔
349
                                        if ( ! \current_user_can( 'publish_pages' ) ) {
2✔
350
                                                $new_post_status = 'pending';
2✔
351
                                        }
352
                                }
353
                                elseif ( ! \current_user_can( 'publish_posts' ) ) {
4✔
354
                                        $new_post_status = 'pending';
2✔
355
                                }
356
                        }
357
                }
358

359
                return $new_post_status;
14✔
360
        }
361

362
        /**
363
         * Generates and returns the author ID for the copy.
364
         *
365
         * @param WP_Post $post    The original post object.
366
         * @param array   $options The options array.
367
         *
368
         * @return int|string The calculated author ID for the copy.
369
         */
370
        public function generate_copy_author( WP_Post $post, array $options ) {
14✔
371
                $new_post_author    = \wp_get_current_user();
14✔
372
                $new_post_author_id = $new_post_author->ID;
14✔
373
                if ( $options['copy_author'] ) {
14✔
374
                        // Check if the user has the right capability.
375
                        if ( \is_post_type_hierarchical( $post->post_type ) ) {
8✔
376
                                if ( \current_user_can( 'edit_others_pages' ) ) {
4✔
377
                                        $new_post_author_id = $post->post_author;
3✔
378
                                }
379
                        }
380
                        elseif ( \current_user_can( 'edit_others_posts' ) ) {
4✔
381
                                $new_post_author_id = $post->post_author;
2✔
382
                        }
383
                }
384

385
                return $new_post_author_id;
14✔
386
        }
387
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc