PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.2-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.2-beta
16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 13.9.2 14.0.1 14.1.1 14.2.2 14.3.1 All 501 releases
jetpack / modules / copy-post.php

copy-post.php in Jetpack – WP Security, Backup, Speed, & Growth 16.2-beta, at modules/copy-post.php

448 lines 15.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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
16 use Automattic\Jetpack\Assets\Logo;
17
18 if ( ! 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 */
29 class 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( wp_slash( $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 */
444 function jetpack_copy_post_init() {
445 new Jetpack_Copy_Post();
446 }
447 add_action( 'admin_init', 'jetpack_copy_post_init' );
448