Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
15.00% covered (danger)
15.00%
21 / 140
5.88% covered (danger)
5.88%
1 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
jetpack_copy_post_init
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
Jetpack_Copy_Post
15.44% covered (danger)
15.44%
21 / 136
6.25% covered (danger)
6.25%
1 / 16
1108.54
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
90
 print_inline_styles
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 update_post_data
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
42
 user_can_access_post
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 update_content
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 update_post_type_terms
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 update_featured_image
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 update_post_format
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 remove_post_format_template
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 update_likes_sharing
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 copy_footnotes
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
7
 filter_title
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 filter_content
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 filter_excerpt
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 validate_post_type
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 add_row_action
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
20
1<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2/**
3 * Module Name: Copy Post
4 * Module Description: Duplicate any post or page in one click to speed up content creation.
5 * Sort Order: 15
6 * First Introduced: 7.0
7 * Requires Connection: No
8 * Auto Activate: No
9 * Module Tags: Writing
10 * Feature: Writing
11 * Additional Search Queries: copy, duplicate
12 *
13 * @package automattic/jetpack
14 */
15
16use Automattic\Jetpack\Assets\Logo;
17
18if ( ! defined( 'ABSPATH' ) ) {
19    exit( 0 );
20}
21
22// phpcs:disable Universal.Files.SeparateFunctionsFromOO.Mixed -- TODO: Move classes to appropriately-named class files.
23
24/**
25 * Copy Post class.
26 *
27 * @phan-constructor-used-for-side-effects
28 */
29class Jetpack_Copy_Post {
30    /**
31     * Jetpack_Copy_Post_By_Param constructor.
32     * Add row actions to post/page/CPT listing screens.
33     * Process any `?copy` param if on a create new post/page/CPT screen.
34     *
35     * @return void
36     */
37    public function __construct() {
38        if ( 'edit.php' === $GLOBALS['pagenow'] || ( 'admin-ajax.php' === $GLOBALS['pagenow'] && ! empty( $_POST['post_view'] ) && 'list' === $_POST['post_view'] && ! empty( $_POST['action'] ) && 'inline-save' === $_POST['action'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- update_post_data() handles access check.
39            add_action( 'admin_head', array( $this, 'print_inline_styles' ) );
40            add_filter( 'post_row_actions', array( $this, 'add_row_action' ), 10, 2 );
41            add_filter( 'page_row_actions', array( $this, 'add_row_action' ), 10, 2 );
42            return;
43        }
44
45        if ( ! empty( $_GET['jetpack-copy'] ) && 'post-new.php' === $GLOBALS['pagenow'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- update_post_data() handles access check.
46            add_action( 'wp_insert_post', array( $this, 'update_post_data' ), 10, 3 );
47            add_filter( 'pre_option_default_post_format', '__return_empty_string' );
48        }
49    }
50
51    /**
52     * Echos inline styles for the Jetpack logo.
53     *
54     * @return void
55     */
56    public function print_inline_styles() {
57        echo '
58            <style id="jetpack-copy-post-styles">
59                #jetpack-logo__icon {
60                    height: 14px;
61                    width: 14px;
62                    vertical-align: text-bottom;
63                }
64                #jetpack-logo__icon path {
65                    fill: inherit;
66                }
67            </style>
68        ';
69    }
70
71    /**
72     * Update the new (target) post data with the source post data.
73     *
74     * @param int     $target_post_id Target post ID.
75     * @param WP_Post $post           Target post object (not used).
76     * @param bool    $update         Whether this is an existing post being updated or not.
77     * @return void
78     */
79    public function update_post_data( $target_post_id, $post, $update ) {
80        // This `$update` check avoids infinite loops of trying to update our updated post.
81        if ( $update ) {
82            return;
83        }
84
85        // Shouldn't happen, since this filter is only added when the value isn't empty, but check anyway.
86        if ( empty( $_GET['jetpack-copy'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
87            return;
88        }
89
90        $source_post = get_post( intval( $_GET['jetpack-copy'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
91        if ( ! $source_post instanceof WP_Post ||
92            ! $this->user_can_access_post( $source_post->ID ) ||
93            ! $this->validate_post_type( $source_post ) ) {
94            return;
95        }
96
97        $update_results = array(
98            'update_content'         => $this->update_content( $source_post, $target_post_id ),
99            'update_featured_image'  => $this->update_featured_image( $source_post, $target_post_id ),
100            'update_post_format'     => $this->update_post_format( $source_post, $target_post_id ),
101            'update_likes_sharing'   => $this->update_likes_sharing( $source_post, $target_post_id ),
102            'update_post_type_terms' => $this->update_post_type_terms( $source_post, $target_post_id ),
103        );
104
105        // Required to satisfy get_default_post_to_edit(), which has these filters after post creation.
106        add_filter( 'default_title', array( $this, 'filter_title' ), 10, 2 );
107        add_filter( 'default_content', array( $this, 'filter_content' ), 10, 2 );
108        add_filter( 'default_excerpt', array( $this, 'filter_excerpt' ), 10, 2 );
109
110        // Required to avoid the block editor from adding default blocks according to post format.
111        add_filter( 'block_editor_settings_all', array( $this, 'remove_post_format_template' ) );
112
113        /**
114         * Fires after all updates have been performed, and default content filters have been added.
115         * Allows for any cleanup or post operations, and default content filters can be removed or modified.
116         *
117         * @module copy-post
118         *
119         * @since 7.0.0
120         *
121         * @param WP_Post $source_post Post object that was copied.
122         * @param int     $target_post_id Target post ID.
123         * @param array   $update_results Results of all update operations, allowing action to be taken.
124         */
125        do_action( 'jetpack_copy_post', $source_post, $target_post_id, $update_results );
126    }
127
128    /**
129     * Determine if the current user has edit access to the source post.
130     *
131     * @param int $post_id Source post ID (the post being copied).
132     * @return bool True if user has the meta cap of `edit_post` for the given post ID, false otherwise.
133     */
134    protected function user_can_access_post( $post_id ) {
135        return current_user_can( 'edit_post', $post_id );
136    }
137
138    /**
139     * Update the target post's title, content, excerpt, categories, and tags.
140     *
141     * @param WP_Post $source_post Post object to be copied.
142     * @param int     $target_post_id Target post ID.
143     * @return int    0 on failure, or the updated post ID on success.
144     */
145    protected function update_content( $source_post, $target_post_id ) {
146        $data = array(
147            'ID'             => $target_post_id,
148            'post_title'     => $source_post->post_title,
149            'post_content'   => $source_post->post_content,
150            'post_excerpt'   => $source_post->post_excerpt,
151            'comment_status' => $source_post->comment_status,
152            'ping_status'    => $source_post->ping_status,
153            'post_category'  => wp_get_post_categories( $source_post->ID ),
154            'post_password'  => $source_post->post_password,
155            'tags_input'     => $source_post->tags_input,
156        );
157
158        // Copy footnotes with regenerated IDs.
159        $data = $this->copy_footnotes( $data, $source_post, $target_post_id );
160
161        /**
162         * Fires just before the target post is updated with its new data.
163         * Allows for final data adjustments before updating the target post.
164         *
165         * @module copy-post
166         *
167         * @since 7.0.0
168         *
169         * @param array $data Post data with which to update the target (new) post.
170         * @param WP_Post $source_post Post object being copied.
171         * @param int     $target_post_id Target post ID.
172         */
173        $data = apply_filters( 'jetpack_copy_post_data', $data, $source_post, $target_post_id );
174        return wp_update_post( $data );
175    }
176
177    /**
178     * Update terms for post types.
179     *
180     * @param WP_Post $source_post Post object to be copied.
181     * @param int     $target_post_id Target post ID.
182     * @return array Results of attempts to set each term to the target (new) post.
183     */
184    protected function update_post_type_terms( $source_post, $target_post_id ) {
185        $results = array();
186
187        $bypassed_post_types = apply_filters( 'jetpack_copy_post_bypassed_post_types', array( 'post', 'page' ), $source_post, $target_post_id );
188        if ( in_array( $source_post->post_type, $bypassed_post_types, true ) ) {
189            return $results;
190        }
191
192        $taxonomies = get_object_taxonomies( $source_post, 'objects' );
193        foreach ( $taxonomies as $taxonomy ) {
194            $terms     = wp_get_post_terms( $source_post->ID, $taxonomy->name, array( 'fields' => 'ids' ) );
195            $results[] = wp_set_post_terms( $target_post_id, $terms, $taxonomy->name );
196        }
197
198        return $results;
199    }
200
201    /**
202     * Update the target post's featured image.
203     *
204     * @param WP_Post $source_post Post object to be copied.
205     * @param int     $target_post_id Target post ID.
206     * @return int|bool Meta ID if the key didn't exist, true on successful update, false on failure.
207     */
208    protected function update_featured_image( $source_post, $target_post_id ) {
209        $featured_image_id = get_post_thumbnail_id( $source_post );
210        return update_post_meta( $target_post_id, '_thumbnail_id', $featured_image_id );
211    }
212
213    /**
214     * Update the target post's post format.
215     *
216     * @param WP_Post $source_post Post object to be copied.
217     * @param int     $target_post_id Target post ID.
218     * @return array|WP_Error|false WP_Error on error, array of affected term IDs on success.
219     */
220    protected function update_post_format( $source_post, $target_post_id ) {
221        $post_format = get_post_format( $source_post );
222        return set_post_format( $target_post_id, $post_format );
223    }
224
225    /**
226     * Ensure the block editor doesn't modify the source post content for non-standard post formats.
227     *
228     * @param array $settings Settings to be passed into the block editor.
229     * @return array Settings with any `template` key removed.
230     */
231    public function remove_post_format_template( $settings ) {
232        unset( $settings['template'] );
233        return $settings;
234    }
235
236    /**
237     * Update the target post's Likes and Sharing statuses.
238     *
239     * @param WP_Post $source_post Post object to be copied.
240     * @param int     $target_post_id Target post ID.
241     * @return array Array with the results of each update action.
242     */
243    protected function update_likes_sharing( $source_post, $target_post_id ) {
244        $likes   = get_post_meta( $source_post->ID, 'switch_like_status', true );
245        $sharing = get_post_meta( $source_post->ID, 'sharing_disabled', true );
246
247        if ( '' !== $likes ) {
248            $likes_result = update_post_meta( $target_post_id, 'switch_like_status', $likes );
249        } else {
250            $likes_result = null;
251        }
252
253        if ( '' !== $sharing ) {
254            $sharing_result = update_post_meta( $target_post_id, 'sharing_disabled', $sharing );
255        } else {
256            $sharing_result = null;
257        }
258
259        return array(
260            'likes'   => $likes_result,
261            'sharing' => $sharing_result,
262        );
263    }
264
265    /**
266     * Copy footnotes from source post, regenerating IDs for uniqueness.
267     *
268     * Gutenberg's Footnotes block stores content in post meta rather than
269     * in the block markup itself. The IDs must be regenerated to ensure uniqueness,
270     * otherwise multiple posts on the same page (e.g., archive views) would
271     * have conflicting footnote anchor links.
272     *
273     * @param array   $data Post data with which to update the target (new) post.
274     * @param WP_Post $source_post Post object being copied.
275     * @param int     $target_post_id Target post ID.
276     * @return array Modified post data.
277     */
278    public function copy_footnotes( $data, $source_post, $target_post_id ) {
279        $footnotes_json = get_post_meta( $source_post->ID, 'footnotes', true );
280
281        if ( '' === $footnotes_json ) {
282            return $data;
283        }
284
285        $footnotes = json_decode( $footnotes_json, true );
286
287        if ( ! is_array( $footnotes ) || empty( $footnotes ) ) {
288            return $data;
289        }
290
291        // Build a mapping of old IDs to new IDs and update the footnotes array.
292        $id_mapping = array();
293        foreach ( $footnotes as &$footnote ) {
294            if ( isset( $footnote['id'] ) ) {
295                $old_id                = $footnote['id'];
296                $new_id                = wp_generate_uuid4();
297                $id_mapping[ $old_id ] = $new_id;
298                $footnote['id']        = $new_id;
299            }
300        }
301        unset( $footnote );
302
303        // Update the post content to use the new footnote IDs.
304        foreach ( $id_mapping as $old_id => $new_id ) {
305            // Replace data-fn attribute values.
306            $data['post_content'] = str_replace( 'data-fn="' . $old_id . '"', 'data-fn="' . $new_id . '"', $data['post_content'] );
307            // Replace href anchor links.
308            $data['post_content'] = str_replace( 'href="#' . $old_id . '"', 'href="#' . $new_id . '"', $data['post_content'] );
309            // Replace id attributes (e.g., id="uuid-link").
310            $data['post_content'] = str_replace( 'id="' . $old_id . '-link"', 'id="' . $new_id . '-link"', $data['post_content'] );
311            $data['post_content'] = str_replace( 'id="' . $old_id . '"', 'id="' . $new_id . '"', $data['post_content'] );
312        }
313
314        // Save the footnotes meta with new IDs.
315        update_post_meta( $target_post_id, 'footnotes', wp_json_encode( $footnotes, JSON_UNESCAPED_SLASHES ) );
316
317        return $data;
318    }
319
320    /**
321     * Update the target post's title.
322     *
323     * @param string  $post_title Post title determined by `get_default_post_to_edit()`.
324     * @param WP_Post $post       Post object of newly-inserted post.
325     * @return string             Updated post title from source post.
326     */
327    public function filter_title( $post_title, $post ) {
328        return $post->post_title;
329    }
330
331    /**
332     * Update the target post's content (`post_content`).
333     *
334     * @param string  $post_content Post content determined by `get_default_post_to_edit()`.
335     * @param WP_Post $post         Post object of newly-inserted post.
336     * @return string               Updated post content from source post.
337     */
338    public function filter_content( $post_content, $post ) {
339        return $post->post_content;
340    }
341
342    /**
343     * Update the target post's excerpt.
344     *
345     * @param string  $post_excerpt Post excerpt determined by `get_default_post_to_edit()`.
346     * @param WP_Post $post         Post object of newly-inserted post.
347     * @return string               Updated post excerpt from source post.
348     */
349    public function filter_excerpt( $post_excerpt, $post ) {
350        return $post->post_excerpt;
351    }
352
353    /**
354     * Validate the post type to be used for the target post.
355     *
356     * @param WP_Post $post Post object of current post in listing.
357     * @return bool True if the post type is in a list of supported psot types; false otherwise.
358     */
359    protected function validate_post_type( $post ) {
360        /**
361         * Fires when determining if the "Copy" row action should be made available.
362         * Allows overriding supported post types.
363         *
364         * @module copy-post
365         *
366         * @since 7.0.0
367         *
368         * @param array   Post types supported by default.
369         * @param WP_Post $post Post object of current post in listing.
370         */
371        $valid_post_types = apply_filters(
372            'jetpack_copy_post_post_types',
373            array(
374                'post',
375                'page',
376                'jetpack-testimonial',
377                'jetpack-portfolio',
378            ),
379            $post
380        );
381        return in_array( $post->post_type, $valid_post_types, true );
382    }
383
384    /**
385     * Add a "Copy" row action to supported posts/pages/CPTs on list views.
386     *
387     * @param array   $actions Existing actions.
388     * @param WP_Post $post    Post object of current post in list.
389     * @return array           Array of updated row actions.
390     */
391    public function add_row_action( $actions, $post ) {
392        if ( ! $this->user_can_access_post( $post->ID ) ||
393            ! $post instanceof WP_Post ||
394            ! $this->validate_post_type( $post ) ) {
395            return $actions;
396        }
397
398        $edit_url = add_query_arg(
399            array(
400                'post_type'    => $post->post_type,
401                'jetpack-copy' => $post->ID,
402            ),
403            admin_url( 'post-new.php' )
404        );
405
406        $jetpack_logo = new Logo();
407        $edit_action  = array(
408            'jetpack-copy' => sprintf(
409                '<a href="%1$s" aria-label="%2$s">%3$s %4$s</a>',
410                esc_url( $edit_url ),
411                esc_attr__( 'Duplicate this post with Jetpack.', 'jetpack' ),
412                esc_html__( 'Duplicate', 'jetpack' ),
413                $jetpack_logo->get_jp_emblem()
414            ),
415        );
416
417        // Insert the Copy action before the Trash action.
418        $edit_offset     = array_search( 'trash', array_keys( $actions ), true );
419        $updated_actions = array_merge(
420            array_slice( $actions, 0, $edit_offset ),
421            $edit_action,
422            array_slice( $actions, $edit_offset )
423        );
424
425        /**
426         * Fires after the new Copy action has been added to the row actions.
427         * Allows changes to the action presentation, or other final checks.
428         *
429         * @module copy-post
430         *
431         * @since 7.0.0
432         *
433         * @param array   $updated_actions Updated row actions with the Copy Post action.
434         * @param array   $actions Original row actions passed to this filter.
435         * @param WP_Post $post Post object of current post in listing.
436         */
437        return apply_filters( 'jetpack_copy_post_row_actions', $updated_actions, $actions, $post );
438    }
439}
440
441/**
442 * Instantiate an instance of Jetpack_Copy_Post on the `admin_init` hook.
443 */
444function jetpack_copy_post_init() {
445    new Jetpack_Copy_Post();
446}
447add_action( 'admin_init', 'jetpack_copy_post_init' );