PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 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 All 508 releases
← All changes | modules/copy-post.php +447 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,447 @@
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 ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Kept for backward compatibility.
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 + // Saved by wp_update_post() in update_content(), which slashes $data to match update_post_meta()'s unslashing.
315 + $data['meta_input']['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' );