| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | <?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName |
| 2 | 2 | /** |
| 3 | 3 | * Module Name: Copy Post |
| 4 | - * Module Description: Enable the option to copy entire posts and pages, including tags and settings | |
| 4 | + * Module Description: Duplicate any post or page in one click to speed up content creation. | |
| 5 | 5 | * Sort Order: 15 |
| 6 | 6 | * First Introduced: 7.0 |
| 7 | 7 | * Requires Connection: No |
| 8 | 8 | * Auto Activate: No |
| @@ -12,12 +12,20 @@ | ||
| 12 | 12 | * |
| 13 | 13 | * @package automattic/jetpack |
| 14 | 14 | */ |
| 15 | 15 | |
| 16 | +use Automattic\Jetpack\Assets\Logo; | |
| 17 | + | |
| 18 | +if ( ! defined( 'ABSPATH' ) ) { | |
| 19 | + exit( 0 ); | |
| 20 | +} | |
| 21 | + | |
| 16 | 22 | // phpcs:disable Universal.Files.SeparateFunctionsFromOO.Mixed -- TODO: Move classes to appropriately-named class files. |
| 17 | 23 | |
| 18 | 24 | /** |
| 19 | 25 | * Copy Post class. |
| 26 | + * | |
| 27 | + * @phan-constructor-used-for-side-effects | |
| 20 | 28 | */ |
| 21 | 29 | class Jetpack_Copy_Post { |
| 22 | 30 | /** |
| 23 | 31 | * Jetpack_Copy_Post_By_Param constructor. |
| @@ -26,9 +34,10 @@ | ||
| 26 | 34 | * |
| 27 | 35 | * @return void |
| 28 | 36 | */ |
| 29 | 37 | public function __construct() { |
| 30 | - if ( 'edit.php' === $GLOBALS['pagenow'] ) { | |
| 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' ) ); | |
| 31 | 40 | add_filter( 'post_row_actions', array( $this, 'add_row_action' ), 10, 2 ); |
| 32 | 41 | add_filter( 'page_row_actions', array( $this, 'add_row_action' ), 10, 2 ); |
| 33 | 42 | return; |
| 34 | 43 | } |
| @@ -39,8 +48,28 @@ | ||
| 39 | 48 | } |
| 40 | 49 | } |
| 41 | 50 | |
| 42 | 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 | + /** | |
| 43 | 72 | * Update the new (target) post data with the source post data. |
| 44 | 73 | * |
| 45 | 74 | * @param int $target_post_id Target post ID. |
| 46 | 75 | * @param WP_Post $post Target post object (not used). |
| @@ -125,8 +154,11 @@ | ||
| 125 | 154 | 'post_password' => $source_post->post_password, |
| 126 | 155 | 'tags_input' => $source_post->tags_input, |
| 127 | 156 | ); |
| 128 | 157 | |
| 158 | + // Copy footnotes with regenerated IDs. | |
| 159 | + $data = $this->copy_footnotes( $data, $source_post, $target_post_id ); | |
| 160 | + | |
| 129 | 161 | /** |
| 130 | 162 | * Fires just before the target post is updated with its new data. |
| 131 | 163 | * Allows for final data adjustments before updating the target post. |
| 132 | 164 | * |
| @@ -138,9 +170,9 @@ | ||
| 138 | 170 | * @param WP_Post $source_post Post object being copied. |
| 139 | 171 | * @param int $target_post_id Target post ID. |
| 140 | 172 | */ |
| 141 | 173 | $data = apply_filters( 'jetpack_copy_post_data', $data, $source_post, $target_post_id ); |
| 142 | - return wp_update_post( $data ); | |
| 174 | + return wp_update_post( wp_slash( $data ) ); | |
| 143 | 175 | } |
| 144 | 176 | |
| 145 | 177 | /** |
| 146 | 178 | * Update terms for post types. |
| @@ -230,8 +262,63 @@ | ||
| 230 | 262 | ); |
| 231 | 263 | } |
| 232 | 264 | |
| 233 | 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 | + /** | |
| 234 | 321 | * Update the target post's title. |
| 235 | 322 | * |
| 236 | 323 | * @param string $post_title Post title determined by `get_default_post_to_edit()`. |
| 237 | 324 | * @param WP_Post $post Post object of newly-inserted post. |
| @@ -307,9 +394,9 @@ | ||
| 307 | 394 | ! $this->validate_post_type( $post ) ) { |
| 308 | 395 | return $actions; |
| 309 | 396 | } |
| 310 | 397 | |
| 311 | - $edit_url = add_query_arg( | |
| 398 | + $edit_url = add_query_arg( | |
| 312 | 399 | array( |
| 313 | 400 | 'post_type' => $post->post_type, |
| 314 | 401 | 'jetpack-copy' => $post->ID, |
| 315 | 402 | ), |
| @@ -314,14 +401,17 @@ | ||
| 314 | 401 | 'jetpack-copy' => $post->ID, |
| 315 | 402 | ), |
| 316 | 403 | admin_url( 'post-new.php' ) |
| 317 | 404 | ); |
| 318 | - $edit_action = array( | |
| 405 | + | |
| 406 | + $jetpack_logo = new Logo(); | |
| 407 | + $edit_action = array( | |
| 319 | 408 | 'jetpack-copy' => sprintf( |
| 320 | - '<a href="%s" aria-label="%s">%s</a>', | |
| 409 | + '<a href="%1$s" aria-label="%2$s">%3$s %4$s</a>', | |
| 321 | 410 | esc_url( $edit_url ), |
| 322 | - esc_attr__( 'Copy this post.', 'jetpack' ), | |
| 323 | - esc_html__( 'Copy', 'jetpack' ) | |
| 411 | + esc_attr__( 'Duplicate this post with Jetpack.', 'jetpack' ), | |
| 412 | + esc_html__( 'Duplicate', 'jetpack' ), | |
| 413 | + $jetpack_logo->get_jp_emblem() | |
| 324 | 414 | ), |
| 325 | 415 | ); |
| 326 | 416 | |
| 327 | 417 | // Insert the Copy action before the Trash action. |