-
+
' . $title . ' ' // phpcs:ignore WordPress.Security.EscapeOutput -- Reason: escaped properly above.
. Newsletter::newsletter_signup_form() // phpcs:ignore WordPress.Security.EscapeOutput -- Reason: escaped in newsletter.php.
. '
@@ -270,12 +276,14 @@ function duplicate_post_dismiss_notice() {
*
* @param int $new_id New post ID.
* @param WP_Post $post The original post object.
+ *
+ * @return void
*/
function duplicate_post_copy_post_taxonomies( $new_id, $post ) {
global $wpdb;
if ( isset( $wpdb->terms ) ) {
// Clear default category (added by wp_insert_post).
- wp_set_object_terms( $new_id, null, 'category' );
+ wp_set_object_terms( $new_id, [], 'category' );
$post_taxonomies = get_object_taxonomies( $post->post_type );
// Several plugins just add support to post-formats but don't register post_format taxonomy.
@@ -283,11 +291,14 @@ function duplicate_post_copy_post_taxonomies( $new_id, $post ) {
$post_taxonomies[] = 'post_format';
}
- $taxonomies_blacklist = get_option( 'duplicate_post_taxonomies_blacklist' );
- if ( $taxonomies_blacklist === '' ) {
+ $taxonomies_blacklist = get_option( 'duplicate_post_taxonomies_blacklist', [] );
+ if ( empty( $taxonomies_blacklist ) ) {
$taxonomies_blacklist = [];
}
- if ( intval( get_option( 'duplicate_post_copyformat' ) ) === 0 ) {
+ elseif ( ! is_array( $taxonomies_blacklist ) ) {
+ $taxonomies_blacklist = [ $taxonomies_blacklist ];
+ }
+ if ( (int) get_option( 'duplicate_post_copyformat' ) === 0 ) {
$taxonomies_blacklist[] = 'post_format';
}
@@ -318,6 +329,8 @@ function duplicate_post_copy_post_taxonomies( $new_id, $post ) {
*
* @param int $new_id The new post ID.
* @param WP_Post $post The original post object.
+ *
+ * @return void
*/
function duplicate_post_copy_post_meta_info( $new_id, $post ) {
$post_meta_keys = get_post_custom_keys( $post->ID );
@@ -337,15 +350,13 @@ function duplicate_post_copy_post_meta_info( $new_id, $post ) {
$meta_blacklist[] = '_edit_last'; // Edit lock.
$meta_blacklist[] = '_dp_is_rewrite_republish_copy';
$meta_blacklist[] = '_dp_has_rewrite_republish_copy';
- if ( intval( get_option( 'duplicate_post_copytemplate' ) ) === 0 ) {
+ if ( (int) get_option( 'duplicate_post_copytemplate' ) === 0 ) {
$meta_blacklist[] = '_wp_page_template';
}
- if ( intval( get_option( 'duplicate_post_copythumbnail' ) ) === 0 ) {
+ if ( (int) get_option( 'duplicate_post_copythumbnail' ) === 0 ) {
$meta_blacklist[] = '_thumbnail_id';
}
- $meta_blacklist = apply_filters_deprecated( 'duplicate_post_blacklist_filter', [ $meta_blacklist ], '3.2.5', 'duplicate_post_excludelist_filter' );
-
/**
* Filters the meta fields excludelist when copying a post.
*
@@ -361,7 +372,7 @@ function duplicate_post_copy_post_meta_info( $new_id, $post ) {
$meta_keys = [];
foreach ( $post_meta_keys as $meta_key ) {
- if ( ! preg_match( '#^' . $meta_blacklist_string . '$#', $meta_key ) ) {
+ if ( ! preg_match( '#^(' . $meta_blacklist_string . ')$#', $meta_key ) ) {
$meta_keys[] = $meta_key;
}
}
@@ -411,7 +422,7 @@ function duplicate_post_addslashes_deep( $value ) {
* @return string|mixed
*/
function duplicate_post_addslashes_to_strings_only( $value ) {
- return Yoast\WP\Duplicate_Post\Utils::addslashes_to_strings_only( $value );
+ return Utils::addslashes_to_strings_only( $value );
}
/**
@@ -429,6 +440,8 @@ function duplicate_post_wp_slash( $value ) {
*
* @param int $new_id The new post ID.
* @param WP_Post $post The original post object.
+ *
+ * @return void
*/
function duplicate_post_copy_attachments( $new_id, $post ) {
// Get thumbnail ID.
@@ -440,7 +453,7 @@ function duplicate_post_copy_attachments( $new_id, $post ) {
'numberposts' => -1,
'post_status' => 'any',
'post_parent' => $post->ID,
- ]
+ ],
);
// Clone old attachments.
foreach ( $children as $child ) {
@@ -463,14 +476,15 @@ function duplicate_post_copy_attachments( $new_id, $post ) {
$new_attachment_id = media_handle_sideload( $file_array, $new_id, $desc );
if ( is_wp_error( $new_attachment_id ) ) {
- unlink( $file_array['tmp_name'] );
+ wp_delete_file( $file_array['tmp_name'] );
continue;
}
$new_post_author = wp_get_current_user();
$cloned_child = [
'ID' => $new_attachment_id,
'post_title' => $child->post_title,
- 'post_exceprt' => $child->post_title,
+ 'post_excerpt' => $child->post_excerpt, // Caption.
+ 'post_content' => $child->post_content, // Description.
'post_author' => $new_post_author->ID,
];
wp_update_post( wp_slash( $cloned_child ) );
@@ -481,7 +495,7 @@ function duplicate_post_copy_attachments( $new_id, $post ) {
}
// If we have cloned the post thumbnail, set the copy as the thumbnail for the new post.
- if ( intval( get_option( 'duplicate_post_copythumbnail' ) ) === 1 && $old_thumbnail_id === $child->ID ) {
+ if ( (int) get_option( 'duplicate_post_copythumbnail' ) === 1 && $old_thumbnail_id === $child->ID ) {
set_post_thumbnail( $new_id, $new_attachment_id );
}
}
@@ -493,6 +507,8 @@ function duplicate_post_copy_attachments( $new_id, $post ) {
* @param int $new_id The new post ID.
* @param WP_Post $post The original post object.
* @param string $status Optional. The destination status.
+ *
+ * @return void
*/
function duplicate_post_copy_children( $new_id, $post, $status = '' ) {
// Get children.
@@ -502,7 +518,7 @@ function duplicate_post_copy_children( $new_id, $post, $status = '' ) {
'numberposts' => -1,
'post_status' => 'any',
'post_parent' => $post->ID,
- ]
+ ],
);
foreach ( $children as $child ) {
@@ -518,6 +534,8 @@ function duplicate_post_copy_children( $new_id, $post, $status = '' ) {
*
* @param int $new_id The new post ID.
* @param WP_Post $post The original post object.
+ *
+ * @return void
*/
function duplicate_post_copy_comments( $new_id, $post ) {
$comments = get_comments(
@@ -525,7 +543,7 @@ function duplicate_post_copy_comments( $new_id, $post ) {
'post_id' => $post->ID,
'order' => 'ASC',
'orderby' => 'comment_date_gmt',
- ]
+ ],
);
$old_id_to_new = [];
@@ -549,7 +567,7 @@ function duplicate_post_copy_comments( $new_id, $post ) {
'comment_karma' => $comment->comment_karma,
'comment_approved' => $comment->comment_approved,
];
- if ( intval( get_option( 'duplicate_post_copydate' ) ) === 1 ) {
+ if ( (int) get_option( 'duplicate_post_copydate' ) === 1 ) {
$commentdata['comment_date'] = $comment->comment_date;
$commentdata['comment_date_gmt'] = get_gmt_from_date( $comment->comment_date );
}
@@ -601,8 +619,8 @@ function duplicate_post_create_duplicate( $post, $status = '', $parent_id = '' )
wp_die(
esc_html(
__( 'Copy features for this post type are not enabled in options page', 'duplicate-post' ) . ': '
- . $post->post_type
- )
+ . $post->post_type,
+ ),
);
}
@@ -612,7 +630,7 @@ function duplicate_post_create_duplicate( $post, $status = '', $parent_id = '' )
if ( $post->post_type !== 'attachment' ) {
$prefix = sanitize_text_field( get_option( 'duplicate_post_title_prefix' ) );
$suffix = sanitize_text_field( get_option( 'duplicate_post_title_suffix' ) );
- if ( intval( get_option( 'duplicate_post_copytitle' ) ) === 1 ) {
+ if ( (int) get_option( 'duplicate_post_copytitle' ) === 1 ) {
$title = $post->post_title;
if ( ! empty( $prefix ) ) {
$prefix .= ' ';
@@ -634,7 +652,7 @@ function duplicate_post_create_duplicate( $post, $status = '', $parent_id = '' )
* }
*/
- if ( intval( get_option( 'duplicate_post_copystatus' ) ) === 0 ) {
+ if ( (int) get_option( 'duplicate_post_copystatus' ) === 0 ) {
$new_post_status = 'draft';
}
elseif ( $new_post_status === 'publish' || $new_post_status === 'future' ) {
@@ -652,7 +670,7 @@ function duplicate_post_create_duplicate( $post, $status = '', $parent_id = '' )
$new_post_author = wp_get_current_user();
$new_post_author_id = $new_post_author->ID;
- if ( intval( get_option( 'duplicate_post_copyauthor' ) ) === 1 ) {
+ if ( (int) get_option( 'duplicate_post_copyauthor' ) === 1 ) {
// Check if the user has the right capability.
if ( is_post_type_hierarchical( $post->post_type ) ) {
if ( current_user_can( 'edit_others_pages' ) ) {
@@ -664,14 +682,14 @@ function duplicate_post_create_duplicate( $post, $status = '', $parent_id = '' )
}
}
- $menu_order = ( intval( get_option( 'duplicate_post_copymenuorder' ) ) === 1 ) ? $post->menu_order : 0;
+ $menu_order = ( (int) get_option( 'duplicate_post_copymenuorder' ) === 1 ) ? $post->menu_order : 0;
$increase_menu_order_by = get_option( 'duplicate_post_increase_menu_order_by' );
if ( ! empty( $increase_menu_order_by ) && is_numeric( $increase_menu_order_by ) ) {
- $menu_order += intval( $increase_menu_order_by );
+ $menu_order += (int) $increase_menu_order_by;
}
$post_name = $post->post_name;
- if ( intval( get_option( 'duplicate_post_copyslug' ) ) !== 1 ) {
+ if ( (int) get_option( 'duplicate_post_copyslug' ) !== 1 ) {
$post_name = '';
}
$new_post_parent = empty( $parent_id ) ? $post->post_parent : $parent_id;
@@ -681,19 +699,19 @@ function duplicate_post_create_duplicate( $post, $status = '', $parent_id = '' )
'comment_status' => $post->comment_status,
'ping_status' => $post->ping_status,
'post_author' => $new_post_author_id,
- 'post_content' => ( intval( get_option( 'duplicate_post_copycontent' ) ) === 1 ) ? $post->post_content : '',
- 'post_content_filtered' => ( intval( get_option( 'duplicate_post_copycontent' ) ) === 1 ) ? $post->post_content_filtered : '',
- 'post_excerpt' => ( intval( get_option( 'duplicate_post_copyexcerpt' ) ) === 1 ) ? $post->post_excerpt : '',
+ 'post_content' => ( (int) get_option( 'duplicate_post_copycontent' ) === 1 ) ? $post->post_content : '',
+ 'post_content_filtered' => ( (int) get_option( 'duplicate_post_copycontent' ) === 1 ) ? $post->post_content_filtered : '',
+ 'post_excerpt' => ( (int) get_option( 'duplicate_post_copyexcerpt' ) === 1 ) ? $post->post_excerpt : '',
'post_mime_type' => $post->post_mime_type,
'post_parent' => $new_post_parent,
- 'post_password' => ( intval( get_option( 'duplicate_post_copypassword' ) ) === 1 ) ? $post->post_password : '',
+ 'post_password' => ( (int) get_option( 'duplicate_post_copypassword' ) === 1 ) ? $post->post_password : '',
'post_status' => $new_post_status,
'post_title' => $title,
'post_type' => $post->post_type,
'post_name' => $post_name,
];
- if ( intval( get_option( 'duplicate_post_copydate' ) ) === 1 ) {
+ if ( (int) get_option( 'duplicate_post_copydate' ) === 1 ) {
$new_post_date = $post->post_date;
$new_post['post_date'] = $new_post_date;
$new_post['post_date_gmt'] = get_gmt_from_date( $new_post_date );
@@ -714,11 +732,22 @@ function duplicate_post_create_duplicate( $post, $status = '', $parent_id = '' )
// information about a post you can hook this action to dupe that data.
if ( $new_post_id !== 0 && ! is_wp_error( $new_post_id ) ) {
+ /**
+ * Fires after a post has been duplicated.
+ *
+ * @param int $new_post_id The ID of the new post.
+ * @param WP_Post $post The original post object.
+ * @param string $status The status of the new post.
+ * @param string $post_type The post type of the duplicated post.
+ */
+ do_action( 'duplicate_post_after_duplicated', $new_post_id, $post, $status, $post->post_type );
+
+ // Deprecated hooks for backward compatibility.
if ( $post->post_type === 'page' || is_post_type_hierarchical( $post->post_type ) ) {
- do_action( 'dp_duplicate_page', $new_post_id, $post, $status );
+ do_action_deprecated( 'dp_duplicate_page', [ $new_post_id, $post, $status ], 'Yoast Duplicate Post 4.6', 'duplicate_post_after_duplicated' );
}
else {
- do_action( 'dp_duplicate_post', $new_post_id, $post, $status );
+ do_action_deprecated( 'dp_duplicate_post', [ $new_post_id, $post, $status ], 'Yoast Duplicate Post 4.6', 'duplicate_post_after_duplicated' );
}
delete_post_meta( $new_post_id, '_dp_original' );
@@ -741,13 +770,13 @@ function duplicate_post_create_duplicate( $post, $status = '', $parent_id = '' )
/**
* Adds some links on the plugin page.
*
- * @param array $links The links array.
- * @param string $file The file name.
- * @return array
+ * @param array
$links The links array.
+ * @param string $file The file name.
+ * @return array
*/
function duplicate_post_add_plugin_links( $links, $file ) {
if ( plugin_basename( __DIR__ . '/duplicate-post.php' ) === $file ) {
- $links[] = '' . esc_html__( 'Documentation', 'duplicate-post' ) . ' ';
+ $links[] = '' . esc_html__( 'Documentation', 'duplicate-post' ) . ' ';
}
return $links;
}
diff --git a/changelog.md b/changelog.md
new file mode 100644
index 000000000..7e9de9aa4
--- /dev/null
+++ b/changelog.md
@@ -0,0 +1,63 @@
+Yoast Duplicate Post
+=========
+Requires at least: 6.8
+Tested up to: 7.0
+Requires PHP: 7.4
+
+Changelog
+=========
+
+## 4.6
+
+Release date: 2026-03-09
+
+Introduces smoother post duplication, more reliable rewrite workflows, and better compatibility across languages and configurations. [Read more here!](https://yoa.st/563)
+
+#### Enhancements
+
+* Improves the style of the _Copy to a new draft_ and _Rewrite & Republish_ actions in the Block Editor.
+* Replaces the metabox with a sidebar panel in the Block Editor.
+* Improves the compatibility with the Block Editor.
+
+#### Bugfixes
+
+* Fixes a bug where the block editor button were not styled if the admin bar links where not present.
+* Fixes a bug where Rewrite & Republish copies could remain orphaned, blocking editors from creating a new Rewrite & Republish copy for the original post.
+* Fixes a bug where cloning an attachment did not copy its caption and description as expected. Props to @masteradhoc.
+* Fixes a bug where notices were not appearing in the block editor, throwing console errors, with some locales.
+* Fixes a bug where translations where missing in the buttons and the notices in the Block Editor. Props to @petitphp.
+* Fixes a bug where using regular expressions in "Do not copy these fields" were not working as expected. Props to @ikuno9233.
+
+#### Other
+
+* Improves security of the Bulk Clone action and the republishing of a copy.
+* Adds `duplicate_post_before_republish` and `duplicate_post_after_republish` action hooks fired before and after republishing. Props to @piscis.
+* Deprecates the `dp_duplicate_post` and `dp_duplicate_page` hooks and introduces a new unified `duplicate_post_after_duplicated` action hook that replaces them. The new hook includes the post type as a fourth parameter for flexible filtering.
+* Sets the minimum supported WordPress version to 6.8.
+* Verified compatibility with PHP up to version 8.5.
+* Sets the WordPress tested up to version to 6.9.
+* Drops compatibility with PHP < 7.4.
+* Fixes the Developer Guide link that was leading to a non-existent page. Props to @masteradhoc.
+* Fixes the documentation link to use a shortlink. Props to @masteradhoc.
+* Improves how the translations are loaded by relying on the WordPress mechanism for that. Props to @swissspidy.
+* Improves discoverability of security policy in Packagist.
+* Users requiring this package via [WP]Packagist can now use the `composer/installers` v2.
+
+## 4.5
+
+Release date: 2022-06-28
+
+#### Enhancements
+
+* Improves the impact of the plugin on the performance of the site by avoiding useless calls on the `gettext` filter.
+
+#### Bugfixes
+
+* Fixes a bug where a section in the Classic Editor's submitbox would be displayed with incorrect margins.
+
+#### Other
+
+* Sets the WordPress tested up to version to 6.0.
+
+### Earlier versions
+For the changelog of earlier versions, please refer to [the changelog on yoast.com](https://yoa.st/duplicate-post-changelog).
diff --git a/common-functions.php b/common-functions.php
index 28545d7d4..f607eeb77 100644
--- a/common-functions.php
+++ b/common-functions.php
@@ -63,6 +63,8 @@ function duplicate_post_get_clone_post_link( $id = 0, $context = 'display', $dra
* @param string $before Optional. Display before edit link.
* @param string $after Optional. Display after edit link.
* @param int $id Optional. Post ID.
+ *
+ * @return void
*/
function duplicate_post_clone_post_link( $link = null, $before = '', $after = '', $id = 0 ) {
$post = get_post( $id );
@@ -75,11 +77,8 @@ function duplicate_post_clone_post_link( $link = null, $before = '', $after = ''
return;
}
- if ( $link === null ) {
- $link = __( 'Copy to a new draft', 'duplicate-post' );
- }
-
- $link = '' . esc_html( $link ) . ' ';
+ $link ??= __( 'Copy to a new draft', 'duplicate-post' );
+ $link = '' . esc_html( $link ) . ' ';
/**
* Filter on the clone link HTML.
diff --git a/compat/jetpack-functions.php b/compat/jetpack-functions.php
index 46819e440..457ee7ff1 100644
--- a/compat/jetpack-functions.php
+++ b/compat/jetpack-functions.php
@@ -10,6 +10,8 @@
/**
* Add handlers for JetPack compatibility.
+ *
+ * @return void
*/
function duplicate_post_jetpack_init() {
add_filter( 'duplicate_post_excludelist_filter', 'duplicate_post_jetpack_add_to_excludelist', 10, 1 );
@@ -39,6 +41,8 @@ function duplicate_post_jetpack_add_to_excludelist( $meta_excludelist ) {
* Disable Markdown.
*
* To be called before copy.
+ *
+ * @return void
*/
function duplicate_post_jetpack_disable_markdown() {
WPCom_Markdown::get_instance()->unload_markdown_for_posts();
@@ -48,6 +52,8 @@ function duplicate_post_jetpack_disable_markdown() {
* Enaable Markdown.
*
* To be called after copy.
+ *
+ * @return void
*/
function duplicate_post_jetpack_enable_markdown() {
WPCom_Markdown::get_instance()->load_markdown_for_posts();
diff --git a/compat/wpml-functions.php b/compat/wpml-functions.php
index 91991d06e..ffc788298 100644
--- a/compat/wpml-functions.php
+++ b/compat/wpml-functions.php
@@ -12,11 +12,12 @@
/**
* Add handlers for WPML compatibility.
+ *
+ * @return void
*/
function duplicate_post_wpml_init() {
if ( defined( 'ICL_SITEPRESS_VERSION' ) ) {
- add_action( 'dp_duplicate_page', 'duplicate_post_wpml_copy_translations', 10, 3 );
- add_action( 'dp_duplicate_post', 'duplicate_post_wpml_copy_translations', 10, 3 );
+ add_action( 'duplicate_post_after_duplicated', 'duplicate_post_wpml_copy_translations', 10, 3 );
add_action( 'shutdown', 'duplicate_wpml_string_packages', 11 );
}
}
@@ -35,13 +36,14 @@ function duplicate_post_wpml_init() {
* @param int $post_id ID of the copy.
* @param WP_Post $post Original post object.
* @param string $status Status of the new post.
+ *
+ * @return void
*/
function duplicate_post_wpml_copy_translations( $post_id, $post, $status = '' ) {
global $sitepress;
global $duplicated_posts;
- remove_action( 'dp_duplicate_page', 'duplicate_post_wpml_copy_translations', 10 );
- remove_action( 'dp_duplicate_post', 'duplicate_post_wpml_copy_translations', 10 );
+ remove_action( 'duplicate_post_after_duplicated', 'duplicate_post_wpml_copy_translations', 10 );
$current_language = $sitepress->get_current_language();
$trid = $sitepress->get_element_trid( $post->ID );
@@ -62,7 +64,7 @@ function duplicate_post_wpml_copy_translations( $post_id, $post, $status = '' )
'post_' . $translation->post_type,
$new_trid,
$code,
- $current_language
+ $current_language,
);
}
}
@@ -78,6 +80,8 @@ function duplicate_post_wpml_copy_translations( $post_id, $post, $status = '' )
* Duplicate string packages.
*
* @global array() $duplicated_posts Array of duplicated posts.
+ *
+ * @return void
*/
function duplicate_wpml_string_packages() { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- Reason: renaming the function would be a BC-break.
global $duplicated_posts;
@@ -108,7 +112,7 @@ function duplicate_wpml_string_packages() { // phpcs:ignore WordPress.NamingConv
$new_string->id,
$language,
$translated_string['value'],
- $translated_string['status']
+ $translated_string['status'],
);
}
}
diff --git a/composer.json b/composer.json
index cb43f5004..c9efddc23 100644
--- a/composer.json
+++ b/composer.json
@@ -1,68 +1,131 @@
{
- "name": "yoast/duplicate-post",
- "description": "The go-to tool for cloning posts and pages, including the powerful Rewrite & Republish feature.",
- "keywords": [
- "wordpress",
- "post",
- "copy",
- "clone"
- ],
- "homepage": "https://wordpress.org/plugins/duplicate-post/",
- "license": "GPL-2.0-or-later",
- "authors": [
- {
- "name": "Enrico Battocchi & Team Yoast",
- "email": "support@yoast.com",
- "homepage": "https://yoast.com"
- }
- ],
- "type": "wordpress-plugin",
- "support": {
- "issues": "https://github.com/Yoast/duplicate-post/issues",
- "forum": "https://wordpress.org/support/plugin/duplicate-post",
- "source": "https://github.com/Yoast/duplicate-post"
- },
- "require": {
- "php": ">=5.6",
- "composer/installers": "^1.12.0"
- },
- "require-dev": {
- "yoast/yoastcs": "^2.2.1",
- "yoast/wp-test-utils": "^1.0.0",
- "roave/security-advisories": "dev-master"
- },
- "autoload": {
- "classmap": [
- "src/"
- ]
- },
- "autoload-dev": {
- "classmap": [
- "tests/"
- ]
- },
- "scripts": {
- "lint": [
- "@php ./vendor/php-parallel-lint/php-parallel-lint/parallel-lint . -e php --show-deprecated --exclude vendor --exclude node_modules --exclude .git"
- ],
- "check-cs": [
- "@php ./vendor/squizlabs/php_codesniffer/bin/phpcs"
- ],
- "fix-cs": [
- "@php ./vendor/squizlabs/php_codesniffer/bin/phpcbf"
- ],
- "test": [
- "@php ./vendor/phpunit/phpunit/phpunit --no-coverage"
- ],
- "coverage": [
- "@php ./vendor/phpunit/phpunit/phpunit"
- ]
- },
- "config": {
- "classmap-authoritative": true,
- "allow-plugins": {
- "dealerdirect/phpcodesniffer-composer-installer": true,
- "composer/installers": true
- }
- }
+ "name": "yoast/duplicate-post",
+ "description": "The go-to tool for cloning posts and pages, including the powerful Rewrite & Republish feature.",
+ "license": "GPL-2.0-or-later",
+ "type": "wordpress-plugin",
+ "keywords": [
+ "wordpress",
+ "post",
+ "copy",
+ "clone"
+ ],
+ "authors": [
+ {
+ "name": "Enrico Battocchi & Team Yoast",
+ "email": "support@yoast.com",
+ "homepage": "https://yoast.com"
+ }
+ ],
+ "homepage": "https://wordpress.org/plugins/duplicate-post/",
+ "support": {
+ "issues": "https://github.com/Yoast/duplicate-post/issues",
+ "forum": "https://wordpress.org/support/plugin/duplicate-post",
+ "source": "https://github.com/Yoast/duplicate-post",
+ "security": "https://yoast.com/security-program/"
+ },
+ "require": {
+ "php": "^7.4 || ^8.0",
+ "composer/installers": "^1.12.0 || ^2.0"
+ },
+ "require-dev": {
+ "yoast/wp-test-utils": "^1.2.1",
+ "yoast/yoastcs": "^3.4.0"
+ },
+ "minimum-stability": "alpha",
+ "prefer-stable": true,
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Yoast\\WP\\Duplicate_Post\\Tests\\": "tests/"
+ },
+ "classmap": [
+ "config/"
+ ]
+ },
+ "config": {
+ "allow-plugins": {
+ "composer/installers": true,
+ "dealerdirect/phpcodesniffer-composer-installer": true
+ },
+ "classmap-authoritative": true,
+ "lock": false
+ },
+ "scripts": {
+ "lint": [
+ "@php ./vendor/php-parallel-lint/php-parallel-lint/parallel-lint . -e php --show-deprecated --exclude vendor --exclude node_modules --exclude .git"
+ ],
+ "cs": [
+ "Yoast\\WP\\Duplicate_Post\\Config\\Composer\\Actions::check_coding_standards"
+ ],
+ "check-cs-thresholds": [
+ "@putenv YOASTCS_THRESHOLD_ERRORS=57",
+ "@putenv YOASTCS_THRESHOLD_WARNINGS=0",
+ "Yoast\\WP\\Duplicate_Post\\Config\\Composer\\Actions::check_cs_thresholds"
+ ],
+ "check-cs": [
+ "@check-cs-warnings -n"
+ ],
+ "check-cs-warnings": [
+ "@php ./vendor/squizlabs/php_codesniffer/bin/phpcs"
+ ],
+ "check-staged-cs": [
+ "@check-cs-warnings --filter=GitStaged"
+ ],
+ "check-branch-cs": [
+ "Yoast\\WP\\Duplicate_Post\\Config\\Composer\\Actions::check_branch_cs"
+ ],
+ "fix-cs": [
+ "@php ./vendor/squizlabs/php_codesniffer/bin/phpcbf"
+ ],
+ "test": [
+ "@php ./vendor/phpunit/phpunit/phpunit --no-coverage"
+ ],
+ "coverage": [
+ "@php ./vendor/phpunit/phpunit/phpunit"
+ ],
+ "test-wp": [
+ "@php ./vendor/phpunit/phpunit/phpunit -c phpunit-wp.xml.dist --no-coverage"
+ ],
+ "coverage-wp": [
+ "@php ./vendor/phpunit/phpunit/phpunit -c phpunit-wp.xml.dist"
+ ],
+ "test-wp-env": [
+ "config/scripts/run-wp-env-tests.sh"
+ ],
+ "coverage-wp-env": [
+ "config/scripts/run-wp-env-tests.sh --coverage"
+ ],
+ "wp-env:stop": [
+ "npx wp-env stop"
+ ],
+ "integration-test": [
+ "@test-wp"
+ ],
+ "integration-coverage": [
+ "@coverage-wp"
+ ]
+ },
+ "scripts-descriptions": {
+ "lint": "Check the PHP files for parse errors.",
+ "cs": "See a menu with the code style checking script options.",
+ "check-cs-thresholds": "Check the PHP files for code style violations and best practices and verify the number of issues does not exceed predefined thresholds.",
+ "check-cs": "Check the PHP files for code style violations and best practices, ignoring warnings.",
+ "check-cs-warnings": "Check the PHP files for code style violations and best practices, including warnings.",
+ "check-staged-cs": "Check the staged PHP files for code style violations and best practices.",
+ "check-branch-cs": "Check the PHP files changed in the current branch for code style violations and best practices.",
+ "fix-cs": "Auto-fix code style violations in the PHP files.",
+ "test": "Run the unit tests without code coverage.",
+ "coverage": "Run the unit tests with code coverage.",
+ "test-wp": "Run the WP unit tests without code coverage.",
+ "coverage-wp": "Run the WP unit tests with code coverage.",
+ "test-wp-env": "Run the WP integration tests via wp-env (Docker) without code coverage.",
+ "coverage-wp-env": "Run the WP integration tests via wp-env (Docker) with code coverage.",
+ "wp-env:stop": "Stop the wp-env Docker environment.",
+ "integration-test": "Deprecated. Alias for the \"test-wp\" script.",
+ "integration-coverage": "Deprecated. Alias for the \"coverage-wp\" script."
+ }
}
diff --git a/config/composer/actions.php b/config/composer/actions.php
new file mode 100644
index 000000000..e4bdf2651
--- /dev/null
+++ b/config/composer/actions.php
@@ -0,0 +1,202 @@
+getIO();
+
+ $choices = [
+ '1' => [
+ 'label' => 'Check staged files for coding standard warnings & errors.',
+ 'command' => 'check-staged-cs',
+ ],
+ '2' => [
+ 'label' => 'Check current branch\'s changed files for coding standard warnings & errors.',
+ 'command' => 'check-branch-cs',
+ ],
+ '3' => [
+ 'label' => 'Check for all coding standard errors.',
+ 'command' => 'check-cs',
+ ],
+ '4' => [
+ 'label' => 'Check for all coding standard warnings & errors.',
+ 'command' => 'check-cs-warnings',
+ ],
+ '5' => [
+ 'label' => 'Fix auto-fixable coding standards.',
+ 'command' => 'fix-cs',
+ ],
+ '6' => [
+ 'label' => 'Verify coding standard violations are below thresholds.',
+ 'command' => 'check-cs-thresholds',
+ ],
+ ];
+
+ $args = $event->getArguments();
+ if ( empty( $args ) ) {
+ foreach ( $choices as $choice => $data ) {
+ $io->write( \sprintf( '%d. %s', $choice, $data['label'] ) );
+ }
+
+ $choice = $io->ask( 'What do you want to do? ' );
+ }
+ else {
+ $choice = $args[0];
+ }
+
+ if ( isset( $choices[ $choice ] ) ) {
+ $event_dispatcher = $event->getComposer()->getEventDispatcher();
+ $event_dispatcher->dispatchScript( $choices[ $choice ]['command'] );
+ }
+ else {
+ $io->write( 'Unknown choice.' );
+ }
+ }
+
+ /**
+ * Runs PHPCS on the files changed in the current branch.
+ *
+ * Used by the composer check-branch-cs command.
+ *
+ * @codeCoverageIgnore
+ *
+ * @param Event $event Composer event that triggered this script.
+ *
+ * @return void
+ */
+ public static function check_branch_cs( Event $event ) {
+ $branch = 'trunk';
+
+ $args = $event->getArguments();
+ if ( ! empty( $args ) ) {
+ $branch = $args[0];
+ }
+
+ exit( self::check_cs_for_changed_files( $branch ) );
+ }
+
+ /**
+ * Runs PHPCS on changed files compared to some git reference.
+ *
+ * @codeCoverageIgnore
+ *
+ * @param string $compare The git reference.
+ *
+ * @return int Exit code passed from the coding standards check.
+ */
+ private static function check_cs_for_changed_files( $compare ) {
+ \exec( 'git diff --name-only --diff-filter=d ' . \escapeshellarg( $compare ), $files );
+
+ $php_files = self::filter_files( $files, '.php' );
+ if ( empty( $php_files ) ) {
+ echo 'No files to compare! Exiting.' . \PHP_EOL;
+
+ return 0;
+ }
+
+ /*
+ * In CI, generate both the normal report as well as the checkstyle report.
+ * The normal report will be shown in the actions output and ensures human readable (and colorized!) results there.
+ * The checkstyle report is used to show the results inline in the GitHub code view.
+ */
+ $extra_args = ( \getenv( 'CI' ) === false ) ? '' : ' --colors --no-cache --report-full --report-checkstyle=./phpcs-report.xml';
+ $command = \sprintf(
+ 'composer check-cs-warnings -- %s %s',
+ \implode( ' ', \array_map( 'escapeshellarg', $php_files ) ),
+ $extra_args,
+ );
+ \system( $command, $exit_code );
+
+ return $exit_code;
+ }
+
+ /**
+ * Checks if the CS errors and warnings are below or at thresholds.
+ *
+ * @return void
+ */
+ public static function check_cs_thresholds() {
+ $in_ci = \getenv( 'CI' );
+
+ echo 'Running coding standards checks, this may take some time.', \PHP_EOL;
+
+ $command = 'composer check-cs-warnings -- -mq --report="YoastCS\\Yoast\\Reports\\Threshold"';
+ if ( $in_ci !== false ) {
+ // Always show the results in CI in color.
+ $command .= ' --colors';
+ }
+ // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Non-WP context, this is fine.
+ @\exec( $command, $phpcs_output, $return );
+
+ $phpcs_output = \implode( \PHP_EOL, $phpcs_output );
+ echo $phpcs_output;
+
+ $above_threshold = true;
+ if ( \strpos( $phpcs_output, 'Coding standards checks have passed!' ) !== false ) {
+ $above_threshold = false;
+ }
+
+ $threshold_exact = true;
+ if ( \strpos( $phpcs_output, ' than the threshold, great job!' ) !== false ) {
+ $threshold_exact = false;
+ }
+
+ /*
+ * Don't run the branch check in CI/GH Actions as it prevents the errors from being shown inline.
+ * The GH Actions script will run this via a separate script step.
+ */
+ if ( $above_threshold === true && $in_ci === false ) {
+ echo \PHP_EOL;
+ echo 'Running check-branch-cs.', \PHP_EOL;
+ echo 'This might show problems on untouched lines. Focus on the lines you\'ve changed first.', \PHP_EOL;
+ echo \PHP_EOL;
+
+ // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Non-WP context, this is fine.
+ @\passthru( 'composer check-branch-cs' );
+ }
+
+ $exit_code = 0;
+ if ( $above_threshold === true || $return > 2 ) {
+ $exit_code = $return;
+ }
+ elseif ( $threshold_exact === false ) {
+ $exit_code = 128;
+ }
+
+ exit( $exit_code );
+ }
+
+ /**
+ * Filter files on extension.
+ *
+ * @param array $files List of files.
+ * @param string $extension Extension to filter on.
+ *
+ * @return array Filtered list of files.
+ */
+ private static function filter_files( array $files, string $extension ): array {
+ return \array_filter(
+ $files,
+ static function ( $file ) use ( $extension ) {
+ return \substr( $file, ( 0 - \strlen( $extension ) ) ) === $extension;
+ },
+ );
+ }
+}
diff --git a/config/grunt/task-config/update-changelog-to-latest.js b/config/grunt/task-config/update-changelog-to-latest.js
index 2dc755e33..91178bf64 100644
--- a/config/grunt/task-config/update-changelog-to-latest.js
+++ b/config/grunt/task-config/update-changelog-to-latest.js
@@ -5,16 +5,17 @@ module.exports = {
"duplicate-post": {
options: {
// header:
- // = 15.7 =
+ // ## 15.7
+ //
// Release Date: January 26th, 2021
//
// Enhancements:
readmeFile: "./readme.txt",
releaseInChangelog: /[=] \d+\.\d+(\.\d+)? =/g,
matchChangelogHeader: /[=]= Changelog ==\n\n/ig,
- newHeadertemplate: "== Changelog ==\n\n" + "= " + "VERSIONNUMBER" + " =\nRelease Date: " + "DATESTRING" + "\n\n",
- matchCorrectHeader: "= " + "VERSIONNUMBER" + "(.|\\n)*?\\n(?=(\\w\+?:\\n|= \\d+[\.\\d]+ =|= Earlier versions =))",
- matchCorrectLines: "= " + "VERSIONNUMBER" + "(.|\\n)*?(?=(= \\d+[\.\\d]+ =|= Earlier versions =))",
+ newHeadertemplate: "== Changelog ==\n\n" + "= " + "VERSIONNUMBER" + " =\n\nRelease date: " + "DATESTRING" + "\n",
+ matchCorrectHeader: "= " + "VERSIONNUMBER" + " =(.|\\n)*?\\n(?=(\\n#### \\w\+?\\n|= \\d+[\.\\d]+|= Earlier versions =))",
+ matchCorrectLines: "= " + "VERSIONNUMBER" + " =(.|\\n)*?(?=(= \\d+[\.\\d]+ =|= Earlier versions =))",
matchCleanedChangelog: "= " + "VERSIONNUMBER" + "(.|\\n)*= Earlier versions =",
replaceCleanedChangelog: "= Earlier versions =",
pluginSlug: "duplicate-post",
diff --git a/config/scripts/install-wp-tests.sh b/config/scripts/install-wp-tests.sh
new file mode 100755
index 000000000..139f38ffc
--- /dev/null
+++ b/config/scripts/install-wp-tests.sh
@@ -0,0 +1,202 @@
+#!/usr/bin/env bash
+
+########################################################################
+# Script to download and install WordPress for use in automated testing.
+#
+# Source: https://github.com/wp-cli/scaffold-command/blob/main/templates/install-wp-tests.sh
+# Last updated based on commit https://github.com/wp-cli/scaffold-command/commit/efdc0aebe792eaa7ddf6725eae45d70fe6c6ce2a
+# dated September 15 2024.
+########################################################################
+
+if [ $# -lt 3 ]; then
+ echo "usage: $0 [db-host] [wp-version] [skip-database-creation]"
+ exit 1
+fi
+
+DB_NAME=$1
+DB_USER=$2
+DB_PASS=$3
+DB_HOST=${4-localhost}
+WP_VERSION=${5-latest}
+SKIP_DB_CREATE=${6-false}
+
+TMPDIR=${TMPDIR-/tmp}
+TMPDIR=$(echo $TMPDIR | sed -e "s/\/$//")
+WP_TESTS_DIR=${WP_TESTS_DIR-$TMPDIR/wordpress-tests-lib}
+WP_CORE_DIR=${WP_CORE_DIR-$TMPDIR/wordpress}
+
+download() {
+ if [ `which curl` ]; then
+ curl -s "$1" > "$2";
+ elif [ `which wget` ]; then
+ wget -nv -O "$2" "$1"
+ else
+ echo "Error: Neither curl nor wget is installed."
+ exit 1
+ fi
+}
+
+# Check if svn is installed
+check_svn_installed() {
+ if ! command -v svn > /dev/null; then
+ echo "Error: svn is not installed. Please install svn and try again."
+ exit 1
+ fi
+}
+
+if [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+\-(beta|RC)[0-9]+$ ]]; then
+ WP_BRANCH=${WP_VERSION%\-*}
+ WP_TESTS_TAG="branches/$WP_BRANCH"
+
+elif [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+$ ]]; then
+ WP_TESTS_TAG="branches/$WP_VERSION"
+elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0-9]+ ]]; then
+ if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then
+ # version x.x.0 means the first release of the major version, so strip off the .0 and download version x.x
+ WP_TESTS_TAG="tags/${WP_VERSION%??}"
+ else
+ WP_TESTS_TAG="tags/$WP_VERSION"
+ fi
+elif [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then
+ WP_TESTS_TAG="trunk"
+else
+ # http serves a single offer, whereas https serves multiple. we only want one
+ download http://api.wordpress.org/core/version-check/1.7/ /tmp/wp-latest.json
+ grep '[0-9]+\.[0-9]+(\.[0-9]+)?' /tmp/wp-latest.json
+ LATEST_VERSION=$(grep -o '"version":"[^"]*' /tmp/wp-latest.json | sed 's/"version":"//')
+ if [[ -z "$LATEST_VERSION" ]]; then
+ echo "Latest WordPress version could not be found"
+ exit 1
+ fi
+ WP_TESTS_TAG="tags/$LATEST_VERSION"
+fi
+set -ex
+
+install_wp() {
+
+ if [ -d $WP_CORE_DIR ]; then
+ return;
+ fi
+
+ mkdir -p $WP_CORE_DIR
+
+ if [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then
+ mkdir -p $TMPDIR/wordpress-trunk
+ rm -rf $TMPDIR/wordpress-trunk/*
+ check_svn_installed
+ svn export --quiet https://core.svn.wordpress.org/trunk $TMPDIR/wordpress-trunk/wordpress
+ mv $TMPDIR/wordpress-trunk/wordpress/* $WP_CORE_DIR
+ else
+ if [ $WP_VERSION == 'latest' ]; then
+ local ARCHIVE_NAME='latest'
+ elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+ ]]; then
+ # https serves multiple offers, whereas http serves single.
+ download https://api.wordpress.org/core/version-check/1.7/ $TMPDIR/wp-latest.json
+ if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then
+ # version x.x.0 means the first release of the major version, so strip off the .0 and download version x.x
+ LATEST_VERSION=${WP_VERSION%??}
+ else
+ # otherwise, scan the releases and get the most up to date minor version of the major release
+ local VERSION_ESCAPED=`echo $WP_VERSION | sed 's/\./\\\\./g'`
+ LATEST_VERSION=$(grep -o '"version":"'$VERSION_ESCAPED'[^"]*' $TMPDIR/wp-latest.json | sed 's/"version":"//' | head -1)
+ fi
+ if [[ -z "$LATEST_VERSION" ]]; then
+ local ARCHIVE_NAME="wordpress-$WP_VERSION"
+ else
+ local ARCHIVE_NAME="wordpress-$LATEST_VERSION"
+ fi
+ else
+ local ARCHIVE_NAME="wordpress-$WP_VERSION"
+ fi
+ download https://wordpress.org/${ARCHIVE_NAME}.tar.gz $TMPDIR/wordpress.tar.gz
+ tar --strip-components=1 -zxmf $TMPDIR/wordpress.tar.gz -C $WP_CORE_DIR
+ fi
+
+ download https://raw.githubusercontent.com/markoheijnen/wp-mysqli/master/db.php $WP_CORE_DIR/wp-content/db.php
+}
+
+install_test_suite() {
+ # portable in-place argument for both GNU sed and Mac OSX sed
+ if [[ $(uname -s) == 'Darwin' ]]; then
+ local ioption='-i.bak'
+ else
+ local ioption='-i'
+ fi
+
+ # set up testing suite if it doesn't yet exist
+ if [ ! -d $WP_TESTS_DIR ]; then
+ # set up testing suite
+ mkdir -p $WP_TESTS_DIR
+ rm -rf $WP_TESTS_DIR/{includes,data}
+ check_svn_installed
+ svn export --quiet --ignore-externals https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/includes/ $WP_TESTS_DIR/includes
+ svn export --quiet --ignore-externals https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/data/ $WP_TESTS_DIR/data
+ fi
+
+ if [ ! -f wp-tests-config.php ]; then
+ download https://develop.svn.wordpress.org/${WP_TESTS_TAG}/wp-tests-config-sample.php "$WP_TESTS_DIR"/wp-tests-config.php
+ # remove all forward slashes in the end
+ WP_CORE_DIR=$(echo $WP_CORE_DIR | sed "s:/\+$::")
+ sed $ioption "s:dirname( __FILE__ ) . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php
+ sed $ioption "s:__DIR__ . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php
+ sed $ioption "s/youremptytestdbnamehere/$DB_NAME/" "$WP_TESTS_DIR"/wp-tests-config.php
+ sed $ioption "s/yourusernamehere/$DB_USER/" "$WP_TESTS_DIR"/wp-tests-config.php
+ sed $ioption "s/yourpasswordhere/$DB_PASS/" "$WP_TESTS_DIR"/wp-tests-config.php
+ sed $ioption "s|localhost|${DB_HOST}|" "$WP_TESTS_DIR"/wp-tests-config.php
+ fi
+
+}
+
+recreate_db() {
+ shopt -s nocasematch
+ if [[ $1 =~ ^(y|yes)$ ]]
+ then
+ mysqladmin drop $DB_NAME -f --user="$DB_USER" --password="$DB_PASS"$EXTRA
+ create_db
+ echo "Recreated the database ($DB_NAME)."
+ else
+ echo "Leaving the existing database ($DB_NAME) in place."
+ fi
+ shopt -u nocasematch
+}
+
+create_db() {
+ mysqladmin create $DB_NAME --user="$DB_USER" --password="$DB_PASS"$EXTRA
+}
+
+install_db() {
+
+ if [ ${SKIP_DB_CREATE} = "true" ]; then
+ return 0
+ fi
+
+ # parse DB_HOST for port or socket references
+ local PARTS=(${DB_HOST//\:/ })
+ local DB_HOSTNAME=${PARTS[0]};
+ local DB_SOCK_OR_PORT=${PARTS[1]};
+ local EXTRA=""
+
+ if ! [ -z $DB_HOSTNAME ] ; then
+ if [ $(echo $DB_SOCK_OR_PORT | grep -e '^[0-9]\{1,\}$') ]; then
+ EXTRA=" --host=$DB_HOSTNAME --port=$DB_SOCK_OR_PORT --protocol=tcp"
+ elif ! [ -z $DB_SOCK_OR_PORT ] ; then
+ EXTRA=" --socket=$DB_SOCK_OR_PORT"
+ elif ! [ -z $DB_HOSTNAME ] ; then
+ EXTRA=" --host=$DB_HOSTNAME --protocol=tcp"
+ fi
+ fi
+
+ # create database
+ if [ $(mysql --user="$DB_USER" --password="$DB_PASS"$EXTRA --execute='show databases;' | grep ^$DB_NAME$) ]
+ then
+ echo "Reinstalling will delete the existing test database ($DB_NAME)"
+ read -p 'Are you sure you want to proceed? [y/N]: ' DELETE_EXISTING_DB
+ recreate_db $DELETE_EXISTING_DB
+ else
+ create_db
+ fi
+}
+
+install_wp
+install_test_suite
+install_db
diff --git a/config/scripts/run-wp-env-tests.sh b/config/scripts/run-wp-env-tests.sh
new file mode 100755
index 000000000..e78ead304
--- /dev/null
+++ b/config/scripts/run-wp-env-tests.sh
@@ -0,0 +1,156 @@
+#!/usr/bin/env bash
+########################################################################
+# Convenience script to run WordPress integration tests via wp-env.
+#
+# Usage:
+# ./config/scripts/run-wp-env-tests.sh # All tests
+# ./config/scripts/run-wp-env-tests.sh --filter=SomeTest # Specific test
+# ./config/scripts/run-wp-env-tests.sh --multisite # Multisite mode
+# ./config/scripts/run-wp-env-tests.sh --coverage # With coverage
+# ./config/scripts/run-wp-env-tests.sh --php=7.4 # Specific PHP version
+# ./config/scripts/run-wp-env-tests.sh --wp=6.8 # Specific WP version
+# ./config/scripts/run-wp-env-tests.sh --php=8.3 --wp=trunk # Both
+#
+# Prerequisites:
+# - Docker running
+# - composer install (must have been run on host)
+# - yarn install (to have wp-env available)
+########################################################################
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
+PLUGIN_PATH="/var/www/html/wp-content/plugins/duplicate-post"
+OVERRIDE_FILE="$PROJECT_DIR/.wp-env.override.json"
+
+# Parse arguments.
+MULTISITE=false
+COVERAGE=false
+PHP_VERSION=""
+WP_VERSION=""
+PHPUNIT_ARGS=()
+
+for arg in "$@"; do
+ case "$arg" in
+ --multisite)
+ MULTISITE=true
+ ;;
+ --coverage)
+ COVERAGE=true
+ ;;
+ --php=*)
+ PHP_VERSION="${arg#--php=}"
+ ;;
+ --wp=*)
+ WP_VERSION="${arg#--wp=}"
+ ;;
+ *)
+ PHPUNIT_ARGS+=("$arg")
+ ;;
+ esac
+done
+
+# Add --no-coverage unless --coverage was explicitly requested.
+if [ "$COVERAGE" = false ]; then
+ PHPUNIT_ARGS+=("--no-coverage")
+fi
+
+# Verify Docker is running.
+if ! docker info > /dev/null 2>&1; then
+ echo "Error: Docker is not running. Please start Docker and try again."
+ exit 1
+fi
+
+# Verify vendor directory exists.
+if [ ! -d "$PROJECT_DIR/vendor" ]; then
+ echo "Error: vendor/ directory not found. Run 'composer install' first."
+ exit 1
+fi
+
+cd "$PROJECT_DIR"
+
+# Handle PHP/WP version overrides via .wp-env.override.json.
+NEEDS_RESTART=false
+
+if [ -n "$PHP_VERSION" ] || [ -n "$WP_VERSION" ]; then
+ # Build the override JSON.
+ OVERRIDE_JSON="{"
+ FIRST=true
+
+ if [ -n "$PHP_VERSION" ]; then
+ OVERRIDE_JSON+="\"phpVersion\":\"$PHP_VERSION\""
+ FIRST=false
+ fi
+
+ if [ -n "$WP_VERSION" ]; then
+ if [ "$FIRST" = false ]; then
+ OVERRIDE_JSON+=","
+ fi
+ # wp-env expects a GitHub reference for core version.
+ if [ "$WP_VERSION" = "trunk" ] || [ "$WP_VERSION" = "nightly" ]; then
+ OVERRIDE_JSON+="\"core\":\"WordPress/WordPress\""
+ else
+ OVERRIDE_JSON+="\"core\":\"WordPress/WordPress#$WP_VERSION\""
+ fi
+ fi
+
+ OVERRIDE_JSON+="}"
+
+ # Check if override file needs updating.
+ if [ -f "$OVERRIDE_FILE" ]; then
+ CURRENT_OVERRIDE=$(cat "$OVERRIDE_FILE")
+ if [ "$CURRENT_OVERRIDE" != "$OVERRIDE_JSON" ]; then
+ NEEDS_RESTART=true
+ fi
+ else
+ NEEDS_RESTART=true
+ fi
+
+ echo "$OVERRIDE_JSON" > "$OVERRIDE_FILE"
+fi
+
+# Start wp-env if not already running, or restart if config changed.
+if [ "$NEEDS_RESTART" = true ]; then
+ echo "Configuration changed. (Re)starting wp-env..."
+ npx wp-env start --update
+elif ! npx wp-env run cli -- wp --info > /dev/null 2>&1; then
+ echo "Starting wp-env..."
+ npx wp-env start
+fi
+
+# Patch wp-tests-config.php to match the standard WP test environment.
+# wp-env sets values that differ from the standard install-wp-tests.sh setup:
+# - WP_HOME/WP_SITEURL hardcoded to localhost (breaks tests that override home URL).
+# - WP_TESTS_DOMAIN set to localhost:port (should be example.org like standard setup).
+WP_TESTS_CONFIG="/wordpress-phpunit/wp-tests-config.php"
+npx wp-env run cli -- bash -c "\
+ sed -i '/define.*WP_SITEURL/d; /define.*WP_HOME/d' $WP_TESTS_CONFIG && \
+ sed -i \"s/define( 'WP_TESTS_DOMAIN', 'localhost:[0-9]*' )/define( 'WP_TESTS_DOMAIN', 'example.org' )/\" $WP_TESTS_CONFIG \
+" > /dev/null 2>&1
+
+# Install PCOV coverage driver if --coverage was requested and it's not already installed.
+if [ "$COVERAGE" = true ]; then
+ if ! npx wp-env run cli -- php -m 2>/dev/null | grep -q pcov; then
+ echo "Installing PCOV coverage driver..."
+ npx wp-env run cli -- sudo bash -c "\
+ pecl install pcov > /dev/null 2>&1 && \
+ echo 'extension=pcov.so' > /usr/local/etc/php/conf.d/docker-php-ext-pcov.ini \
+ " > /dev/null 2>&1
+ fi
+fi
+
+# Build the environment variables prefix for the command.
+ENV_PREFIX="WP_TESTS_DIR=/wordpress-phpunit/"
+
+if [ "$MULTISITE" = true ]; then
+ ENV_PREFIX="WP_TESTS_DIR=/wordpress-phpunit/ WP_MULTISITE=1"
+fi
+
+echo "Running integration tests..."
+npx wp-env run cli \
+ --env-cwd="$PLUGIN_PATH" \
+ -- env $ENV_PREFIX \
+ php vendor/phpunit/phpunit/phpunit \
+ -c phpunit-wp.xml.dist \
+ "${PHPUNIT_ARGS[@]}"
diff --git a/config/webpack/paths.js b/config/webpack/paths.js
index 10d44c722..7a35ca22e 100644
--- a/config/webpack/paths.js
+++ b/config/webpack/paths.js
@@ -13,25 +13,8 @@ const entry = {
"duplicate-post-elementor": "./duplicate-post-elementor.js",
};
-/**
- * Flattens a version for usage in a filename.
- *
- * @param {string} version The version to flatten.
- *
- * @returns {string} The flattened version.
- */
-function flattenVersionForFile( version ) {
- const versionParts = version.split( "." );
- if ( versionParts.length === 2 && /^\d+$/.test( versionParts[1] ) ) {
- versionParts.push( 0 );
- }
-
- return versionParts.join( "" );
-}
-
module.exports = {
entry,
jsDist: jsDistPath,
jsSrc: jsSrcPath,
- flattenVersionForFile,
};
diff --git a/config/webpack/webpack.config.js b/config/webpack/webpack.config.js
index 2aed6b4f5..2a1f25d4e 100644
--- a/config/webpack/webpack.config.js
+++ b/config/webpack/webpack.config.js
@@ -1,11 +1,8 @@
-const CaseSensitivePathsPlugin = require( "case-sensitive-paths-webpack-plugin" );
-
const {
camelCaseDash,
} = require( "@wordpress/dependency-extraction-webpack-plugin/lib/util" );
const paths = require( "./paths" );
-const pkg = require( "../../package.json" );
const externals = {
// This is necessary for Gutenberg to work.
@@ -31,7 +28,7 @@ const wordpressPackages = [
"@wordpress/data",
"@wordpress/dom",
"@wordpress/dom-ready",
- "@wordpress/edit-post",
+ "@wordpress/editor",
"@wordpress/element",
"@wordpress/html-entities",
"@wordpress/i18n",
@@ -52,10 +49,8 @@ const wordpressExternals = wordpressPackages.reduce( ( memo, packageName ) => {
}, {} );
-function getOutputFilename( mode ) {
- const pluginVersionSlug = paths.flattenVersionForFile( pkg.yoast.pluginVersion );
-
- return "[name]-" + pluginVersionSlug + ".js";
+function getOutputFilename() {
+ return "[name].js";
}
module.exports = ( env = { environment: "production" } ) => {
@@ -63,7 +58,7 @@ module.exports = ( env = { environment: "production" } ) => {
const config = {
mode,
- devtool: mode === "development" ? "cheap-module-eval-source-map" : false,
+ devtool: mode === "development" ? "eval-cheap-module-source-map" : false,
entry: paths.entry,
context: paths.jsSrc,
optimization: {
@@ -75,8 +70,8 @@ module.exports = ( env = { environment: "production" } ) => {
},
output: {
path: paths.jsDist,
- filename: getOutputFilename( mode ),
- jsonpFunction: "duplicatePostWebpackJsonp",
+ filename: getOutputFilename(),
+ chunkLoadingGlobal: "duplicatePostWebpackJsonp",
},
resolve: {
extensions: [ ".js", ".jsx" ],
@@ -99,9 +94,7 @@ module.exports = ( env = { environment: "production" } ) => {
},
],
},
- plugins: [
- new CaseSensitivePathsPlugin(),
- ],
+ plugins: [],
};
if ( mode === "development" ) {
diff --git a/css/duplicate-post.css b/css/duplicate-post.css
index abdd2f110..c820fc370 100644
--- a/css/duplicate-post.css
+++ b/css/duplicate-post.css
@@ -32,10 +32,15 @@
/* Copy links in the block editor. */
.components-button.dp-editor-post-copy-to-draft,
.components-button.dp-editor-post-rewrite-republish {
- margin-left: -6px;
- text-decoration: underline;
+ width: 100%;
+ justify-content: center;
+}
+
+.components-button.dp-editor-post-copy-to-draft{
+ margin-bottom: 4px;
}
+
#check-changes-action {
padding: 6px 10px 8px;
}
@@ -89,3 +94,22 @@ fieldset#duplicate_post_quick_edit_fieldset label{
fieldset#duplicate_post_quick_edit_fieldset a{
text-decoration: underline;
}
+
+/* Block editor sidebar panel. */
+.duplicate-post-panel .duplicate-post-original-item {
+ margin-bottom: 0;
+}
+
+.duplicate-post-panel .duplicate-post-remove-connection-button {
+ margin-top: 16px;
+ width: 100%;
+ justify-content: center;
+}
+
+.duplicate-post-modal-buttons {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ margin-top: 16px;
+}
+
diff --git a/duplicate-post.php b/duplicate-post.php
index 606847919..3f2b401d6 100644
--- a/duplicate-post.php
+++ b/duplicate-post.php
@@ -9,12 +9,14 @@
* Plugin Name: Yoast Duplicate Post
* Plugin URI: https://yoast.com/wordpress/plugins/duplicate-post/
* Description: The go-to tool for cloning posts and pages, including the powerful Rewrite & Republish feature.
- * Version: 4.5
+ * Version: 4.6
* Author: Enrico Battocchi & Team Yoast
- * Author URI: https://yoast.com
+ * Author URI: https://yoa.st/team-yoast-duplicate
* Text Domain: duplicate-post
+ * Requires at least: 6.8
+ * Requires PHP: 7.4
*
- * Copyright 2020-2022 Yoast BV (email : info@yoast.com)
+ * Copyright 2020-2024 Yoast BV (email : info@yoast.com)
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -45,7 +47,7 @@
define( 'DUPLICATE_POST_PATH', plugin_dir_path( __FILE__ ) );
}
-define( 'DUPLICATE_POST_CURRENT_VERSION', '4.5' );
+define( 'DUPLICATE_POST_CURRENT_VERSION', '4.6' );
$duplicate_post_autoload_file = DUPLICATE_POST_PATH . 'vendor/autoload.php';
@@ -66,20 +68,14 @@
* @phpcs:disable PHPCompatibility.FunctionNameRestrictions.ReservedFunctionNames.FunctionDoubleUnderscore
* @phpcs:disable WordPress.NamingConventions.ValidFunctionName.FunctionDoubleUnderscore
* @phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound
+ *
+ * @return void
*/
function __duplicate_post_main() {
new Duplicate_Post();
}
// phpcs:enable
-/**
- * Initialises the internationalisation domain.
- */
-function duplicate_post_load_plugin_textdomain() {
- load_plugin_textdomain( 'duplicate-post', false, basename( __DIR__ ) . '/languages/' );
-}
-add_action( 'plugins_loaded', 'duplicate_post_load_plugin_textdomain' );
-
add_filter( 'plugin_action_links_' . plugin_basename( __FILE__ ), 'duplicate_post_plugin_actions', 10 );
/**
@@ -87,16 +83,17 @@ function duplicate_post_load_plugin_textdomain() {
*
* @see 'plugin_action_links_$plugin_file'
*
- * @param array $actions An array of plugin action links.
- * @return array
+ * @param array $actions An array of plugin action links.
+ * @return array
*/
function duplicate_post_plugin_actions( $actions ) {
$settings_action = [
'settings' => sprintf(
'%3$s ',
menu_page_url( 'duplicatepost', false ),
+ /* translators: Hidden accessibility text. */
'aria-label="' . __( 'Settings for Duplicate Post', 'duplicate-post' ) . '"',
- esc_html__( 'Settings', 'duplicate-post' )
+ esc_html__( 'Settings', 'duplicate-post' ),
),
];
diff --git a/js/src/duplicate-post-edit-script.js b/js/src/duplicate-post-edit-script.js
index ccbb7645e..3587ef8d0 100644
--- a/js/src/duplicate-post-edit-script.js
+++ b/js/src/duplicate-post-edit-script.js
@@ -1,13 +1,168 @@
/* global duplicatePost, duplicatePostNotices */
+import { useState } from 'react';
import { registerPlugin } from "@wordpress/plugins";
-import { PluginPostStatusInfo } from "@wordpress/edit-post";
+import { PluginDocumentSettingPanel, PluginPostStatusInfo } from "@wordpress/editor";
import { Fragment } from "@wordpress/element";
-import { Button } from '@wordpress/components';
+import { Button, ExternalLink, Modal } from '@wordpress/components';
import { __ } from "@wordpress/i18n";
import { select, subscribe, dispatch } from "@wordpress/data";
+import apiFetch from "@wordpress/api-fetch";
import { redirectOnSaveCompletion } from "./duplicate-post-functions";
+/**
+ * Functional component for the Duplicate Post sidebar panel.
+ *
+ * @returns {JSX.Element|null} The rendered panel or null.
+ */
+function DuplicatePostPanel() {
+ const [ isConfirmOpen, setIsConfirmOpen ] = useState( false );
+ const [ isRemoving, setIsRemoving ] = useState( false );
+ const [ referenceRemoved, setReferenceRemoved ] = useState( false );
+
+ const originalItem = duplicatePost.originalItem;
+ const isRewriting = parseInt( duplicatePost.rewriting, 10 );
+ const showMetaBox = duplicatePost.showOriginalMetaBox && originalItem && ! referenceRemoved;
+
+ /**
+ * Handles the removal of the original reference via REST API.
+ *
+ * @returns {void}
+ */
+ const handleRemoveOriginal = async () => {
+ setIsRemoving( true );
+ try {
+ await apiFetch( {
+ path: `/duplicate-post/v1/original/${ duplicatePost.postId }`,
+ method: 'DELETE',
+ } );
+ setReferenceRemoved( true );
+ setIsConfirmOpen( false );
+ } catch ( error ) {
+ // eslint-disable-next-line no-console
+ console.error( 'Failed to remove original reference:', error );
+ dispatch( 'core/notices' ).createNotice(
+ 'error',
+ __( 'Failed to remove the connection to the original post. Please try again.', 'duplicate-post' ),
+ {
+ isDismissible: true,
+ }
+ );
+ } finally {
+ setIsRemoving( false );
+ }
+ };
+
+ if ( ! showMetaBox ) {
+ return null;
+ }
+
+ return (
+
+
+ { __( 'The original item this was copied from is:', 'duplicate-post' ) }
+ { ' ' }
+
+ { originalItem.canEdit ? (
+
+ { originalItem.title }
+
+ ) : (
+
+ { originalItem.title }
+
+ ) }
+
+
+ { ! isRewriting &&
+ setIsConfirmOpen( true ) }
+ className="duplicate-post-remove-connection-button"
+ >
+ { __( "Remove connection", "duplicate-post" ) }
+
+ }
+ { isConfirmOpen &&
+ setIsConfirmOpen( false ) }
+ >
+
+ { __( "Are you sure you want to remove the connection to the original post? This action cannot be undone.", "duplicate-post" ) }
+
+
+ setIsConfirmOpen( false ) }
+ >
+ { __( "Cancel", "duplicate-post" ) }
+
+
+ { __( "Remove", "duplicate-post" ) }
+
+
+
+ }
+
+ );
+}
+
+/**
+ * Functional component for the Duplicate Post plugin render.
+ *
+ * @returns {JSX.Element|null} The rendered component or null.
+ */
+function DuplicatePostRender() {
+ // Don't try to render anything if there is no store.
+ if ( ! select( 'core/editor' ) || ! ( wp.editor && wp.editor.PluginPostStatusInfo ) ) {
+ return null;
+ }
+
+ const currentPostStatus = select( 'core/editor' ).getEditedPostAttribute( 'status' );
+
+ return (
+
+ { ( duplicatePost.showLinksIn.submitbox === '1' ) &&
+
+ { ( duplicatePost.newDraftLink !== '' && duplicatePost.showLinks.new_draft === '1' ) &&
+
+
+ { __( 'Copy to a new draft', 'duplicate-post' ) }
+
+
+ }
+ { ( currentPostStatus === 'publish' && duplicatePost.rewriteAndRepublishLink !== '' && duplicatePost.showLinks.rewrite_republish === '1' ) &&
+
+
+ { __( 'Rewrite & Republish', 'duplicate-post' ) }
+
+
+ }
+
+ }
+
+
+ );
+}
class DuplicatePost {
constructor() {
@@ -95,14 +250,13 @@ class DuplicatePost {
return;
}
- for ( const [ key, notice ] of Object.entries( duplicatePostNotices ) ){
- let noticeObj = JSON.parse( notice );
- if ( noticeObj.status && noticeObj.text ) {
+ for ( const [ key, notice ] of Object.entries( duplicatePostNotices ) ) {
+ if ( notice.status && notice.text ) {
dispatch( 'core/notices' ).createNotice(
- noticeObj.status,
- noticeObj.text,
+ notice.status,
+ notice.text,
{
- isDismissible: noticeObj.isDismissible || true,
+ isDismissible: notice.isDismissible || true,
}
);
}
@@ -116,50 +270,8 @@ class DuplicatePost {
*/
removeSlugSidebarPanel() {
if ( parseInt( duplicatePost.rewriting, 10 ) ) {
- dispatch( 'core/edit-post' ).removeEditorPanel( 'post-link' );
- }
- }
-
- /**
- * Renders the links in the PluginPostStatusInfo component.
- *
- * @returns {JSX.Element} The rendered links.
- */
- render() {
- // Don't try to render anything if there is no store.
- if ( ! select( 'core/editor' ) || ! ( wp.editPost && wp.editPost.PluginPostStatusInfo ) ) {
- return null;
+ dispatch( 'core/editor' ).removeEditorPanel( 'post-link' );
}
-
- const currentPostStatus = select( 'core/editor' ).getEditedPostAttribute( 'status' );
-
- return (
- ( duplicatePost.showLinksIn.submitbox === '1' ) &&
-
- { ( duplicatePost.newDraftLink !== '' && duplicatePost.showLinks.new_draft === '1' ) &&
-
-
- { __( 'Copy to a new draft', 'duplicate-post' ) }
-
-
- }
- { ( currentPostStatus === 'publish' && duplicatePost.rewriteAndRepublishLink !== '' && duplicatePost.showLinks.rewrite_republish === '1' ) &&
-
-
- { __( 'Rewrite & Republish', 'duplicate-post' ) }
-
-
- }
-
- );
}
}
@@ -167,5 +279,5 @@ const instance = new DuplicatePost();
instance.handleRedirect();
registerPlugin( 'duplicate-post', {
- render: instance.render
+ render: DuplicatePostRender
} );
diff --git a/js/src/duplicate-post-strings.js b/js/src/duplicate-post-strings.js
index cf1b3c848..cb09d3377 100644
--- a/js/src/duplicate-post-strings.js
+++ b/js/src/duplicate-post-strings.js
@@ -1,6 +1,6 @@
/* global duplicatePostStrings */
-import { createInterpolateElement } from "@wordpress/element";
+import { safeCreateInterpolateElement } from "./helpers/safe-create-interpolate-element";
import { Button } from "@wordpress/components";
import { __, setLocaleData } from "@wordpress/i18n";
import { dispatch, subscribe } from "@wordpress/data";
@@ -34,7 +34,7 @@ const republishStrings = {
'Are you ready to publish?' : __( 'Are you ready to republish your post?', 'duplicate-post' ),
'Double-check your settings before publishing.':
- createInterpolateElement(
+ safeCreateInterpolateElement(
__( 'After republishing your changes will be merged into the original post and you\'ll be redirected there. Do you want to compare your changes with the original version before merging?Save changes and compare ',
'duplicate-post' ),
{
@@ -49,7 +49,7 @@ const republishStrings = {
'Are you ready to schedule?' : __( 'Are you ready to schedule the republishing of your post?', 'duplicate-post' ),
'Your work will be published at the specified date and time.':
- createInterpolateElement(
+ safeCreateInterpolateElement(
__( 'You\'re about to replace the original with this rewritten post at the specified date and time. Do you want to compare your changes with the original version before merging?Save changes and compare ',
'duplicate-post' ),
{
diff --git a/js/src/helpers/safe-create-interpolate-element.js b/js/src/helpers/safe-create-interpolate-element.js
new file mode 100644
index 000000000..3c0352661
--- /dev/null
+++ b/js/src/helpers/safe-create-interpolate-element.js
@@ -0,0 +1,17 @@
+import { createInterpolateElement } from "@wordpress/element";
+
+/**
+ * Wrapper function for `createInterpolateElement` to catch errors.
+ *
+ * @param {string} interpolatedString The interpolated string.
+ * @param {Object} conversionMap The conversion map object.
+ * @returns {JSX.Element|string} The interpolated element or string if it failed.
+ */
+export const safeCreateInterpolateElement = ( interpolatedString, conversionMap ) => {
+ try {
+ return createInterpolateElement( interpolatedString, conversionMap );
+ } catch ( error ) {
+ console.error( "Error in translation for:", interpolatedString, error );
+ return interpolatedString;
+ }
+};
diff --git a/options.php b/options.php
index b840e4309..c613983e6 100644
--- a/options.php
+++ b/options.php
@@ -15,7 +15,7 @@
$duplicate_post_options_page = new Options_Page(
new Options(),
new Options_Form_Generator( new Options_Inputs() ),
- new Asset_Manager()
+ new Asset_Manager(),
);
$duplicate_post_options_page->register_hooks();
diff --git a/package.json b/package.json
index 769b43329..c3dd50c4a 100644
--- a/package.json
+++ b/package.json
@@ -27,33 +27,30 @@
},
"homepage": "https://github.com/Yoast/duplicate-post#readme",
"devDependencies": {
- "@wordpress/data": "^4.25.0",
- "@wordpress/dependency-extraction-webpack-plugin": "^2.8.0",
- "@yoast/grunt-plugin-tasks": "^2.3",
- "babel-core": "^6.26.3",
- "babel-eslint": "^10.1.0",
- "babel-loader": "7",
- "babel-plugin-dynamic-import-webpack": "^1.1.0",
- "babel-plugin-transform-object-rest-spread": "^6.26.0",
- "babel-plugin-transform-react-jsx": "^6.24.1",
- "babel-preset-env": "^1.7.0",
- "babel-preset-es2015": "^6.24.1",
- "babel-preset-react": "^6.24.1",
- "babelify": "^10.0.0",
- "case-sensitive-paths-webpack-plugin": "^2.3.0",
- "grunt": "^1.3.0",
- "grunt-git": "^1.0.14",
+ "@babel/core": "^7.18.5",
+ "@babel/plugin-transform-runtime": "^7.18.5",
+ "@babel/preset-env": "^7.18.5",
+ "@babel/preset-react": "^7.18.5",
+ "@babel/runtime": "^7.18.5",
+ "@wordpress/dependency-extraction-webpack-plugin": "^6.6.0",
+ "@wordpress/env": "^11.2.0",
+ "@wordpress/scripts": "^28.6.0",
+ "@yoast/grunt-plugin-tasks": "^2.5",
+ "babel-loader": "^9.1.3",
+ "grunt": "^1.5.3",
"grunt-contrib-clean": "^2.0.0",
+ "grunt-git": "^1.0.14",
"grunt-shell": "^3.0.1",
- "grunt-webpack": "^4.0.2",
+ "grunt-webpack": "^6.0.0",
"load-grunt-config": "^3.0.1",
- "webpack": "^4.20.2"
+ "webpack": "^5.94.0"
},
"dependencies": {
- "@wordpress/edit-post": "^3.25.2",
+ "@wordpress/data": "^4.25.0",
+ "@wordpress/editor": "^9.25.0",
"@wordpress/plugins": "^2.23.0"
},
"yoast": {
- "pluginVersion": "4.5"
+ "pluginVersion": "4.6"
}
}
diff --git a/phpunit-wp.xml.dist b/phpunit-wp.xml.dist
new file mode 100644
index 000000000..bfa74d8b2
--- /dev/null
+++ b/phpunit-wp.xml.dist
@@ -0,0 +1,43 @@
+
+
+
+
+ ./tests/WP
+
+
+
+
+
+ ./admin-functions.php
+ ./common-functions.php
+ ./duplicate-post.php
+ ./options.php
+ ./compat
+ ./src
+
+
+
+
+
+
+
+
+
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index fb6dd91c3..bddc0b1f9 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -1,10 +1,10 @@
- ./tests/
+ ./tests/Unit
+ ./admin-functions.php
+ ./common-functions.php
+ ./duplicate-post.php
+ ./options.php
+ ./compat
./src
+
+
+
+
+
+
diff --git a/readme.txt b/readme.txt
index 8997254c2..7c65ffa90 100644
--- a/readme.txt
+++ b/readme.txt
@@ -2,10 +2,10 @@
Contributors: yoast, lopo
Donate link: https://yoast.com/wordpress/plugins/duplicate-post/
Tags: duplicate post, copy, clone
-Requires at least: 5.8
-Tested up to: 6.0
-Stable tag: 4.5
-Requires PHP: 5.6.20
+Requires at least: 6.8
+Tested up to: 7.0
+Stable tag: 4.6
+Requires PHP: 7.4
License: GPLv2 or later
License URI: http://www.gnu.org/licenses/gpl-2.0.html
@@ -31,7 +31,7 @@ How it works:
There is also a **template tag**, so you can put it in your templates and clone your posts/pages from the front-end. Clicking on the link will lead you to the edit page for the new draft, just like the admin bar link.
-Duplicate Post has many useful settings to customize its behavior and restrict its use to certain roles or post types. Check out the extensive documentation [on yoast.com](https://yoast.com/wordpress/plugins/duplicate-post/) and our [developer docs](https://developer.yoast.com/duplicate-post/).
+Duplicate Post has many useful settings to customize its behavior and restrict its use to certain roles or post types. Check out the extensive documentation on [yoast.com](https://yoast.com/wordpress/plugins/duplicate-post/) and our [developer docs](https://developer.yoast.com/duplicate-post/overview/).
== Installation ==
@@ -71,37 +71,55 @@ If Duplicate Post is still in English, or if there are some untranslated strings
== Changelog ==
-= 4.5 =
-Release Date: June 28th, 2022
+= 4.6 =
-Enhancements:
+Release date: 2026-03-09
-* Improves the impact of the plugin on the performance of the site by avoiding useless calls on the `gettext` filter.
+#### Enhancements
-Bugfixes:
+* Improves the compatibility with the Block Editor.
+* Improves the style of the _Copy to a new draft_ and _Rewrite & Republish_ actions in the Block Editor.
+* Replaces the metabox with a sidebar panel in the Block Editor.
-* Fixes a bug where a section in the Classic Editor's submitbox would be displayed with incorrect margins.
+#### Bugfixes
-Other:
+* Fixes a bug where cloning an attachment did not copy its caption as expected. Props to @masteradhoc.
+* Fixes a bug where cloning an attachment did not copy its description as expected.
+* Fixes a bug where notices would not be appearing in the block editor, throwing console errors, with some locales.
+* Fixes a bug where Rewrite & Republish copies could remain orphaned, blocking editors from creating a new Rewrite & Republish copy for the original post.
+* Fixes a bug where the block editor button were not styled if the admin bar links where not present.
+* Fixes a bug where translations where missing in the buttons and the notices in the Block Editor. Props to @petitphp.
-* Sets the WordPress tested up to version to 6.0.
+#### Other
+
+* Improves security of the Bulk Clone action and the republishing of a copy.
+* Adds `duplicate_post_before_republish` and `duplicate_post_after_republish` action hooks fired before and after republishing. Props to @piscis.
+* Deprecates the `dp_duplicate_post` and `dp_duplicate_page` hooks and introduces a new unified `duplicate_post_after_duplicated` action hook that replaces them. The new hook includes the post type as a fourth parameter for flexible filtering.
+* Sets the minimum supported WordPress version to 6.8.
+* Sets the WordPress tested up to version to 6.9.
+* Drops compatibility with PHP < 7.4.
+* Verified compatibility with PHP up to version 8.5.
+* Fixes the Developer Guide link that was leading to a non-existent page. Props to @masteradhoc.
+* Fixes the documentation link to use a shortlink. Props to @masteradhoc.
+* Improves how the translations are loaded by relying on the WordPress mechanism for that. Props to @swissspidy.
+* Improves discoverability of security policy in Packagist.
+* Users requiring this package via [WP]Packagist can now use the `composer/installers` v2.
-= 4.4 =
-Release Date: January 25th, 2022
+= 4.5 =
-Enhancements:
+Release date: 2022-06-28
-* Converts the upgrade notice into a welcome notice for first-time users.
+#### Enhancements
-Bugfixes:
+* Improves the impact of the plugin on the performance of the site by avoiding useless calls on the `gettext` filter.
-* Fixes a bug where HTML tags in a Custom HTML block would be removed when republishing a scheduled Rewrite & Republish copy.
-* Fixes a bug where the button style would be broken in the Classic Editor.
-* Fixes a bug where a fatal error would be triggered in the Widgets page in combination with some themes or plugins.
+#### Bugfixes
-Other:
+* Fixes a bug where a section in the Classic Editor's submitbox would be displayed with incorrect margins.
-* Sets the WordPress tested up to version to 5.9.
+#### Other
+
+* Sets the WordPress tested up to version to 6.0.
= Earlier versions =
For the changelog of earlier versions, please refer to [the changelog on yoast.com](https://yoa.st/duplicate-post-changelog).
diff --git a/src/admin/options-form-generator.php b/src/admin/options-form-generator.php
index dbe9d13ba..c5186d876 100644
--- a/src/admin/options-form-generator.php
+++ b/src/admin/options-form-generator.php
@@ -78,7 +78,7 @@ public function generate_options_input( array $options, $parent_option = '' ) {
$option,
$option_values['value'],
$id,
- $this->is_checked( $option, $option_values, $parent_option )
+ $this->is_checked( $option, $option_values, $parent_option ),
);
$output .= \sprintf( '%s ', $id, \esc_html( $option_values['label'] ) );
@@ -127,8 +127,8 @@ public function sort_taxonomy_objects( $taxonomy1, $taxonomy2 ) {
/**
* Extracts and formats the description associated with the input field.
*
- * @param string|array $description The description string. Can be an array of strings.
- * @param string $id The ID of the input field.
+ * @param string|array $description The description string. Can be an array of strings.
+ * @param string $id The ID of the input field.
*
* @return string The description HTML for the input.
*/
@@ -176,7 +176,7 @@ public function generate_taxonomy_exclusion_list() {
'checked' => \in_array( $taxonomy->name, $taxonomies_blacklist, true ),
'label' => $taxonomy->labels->name . ' [' . $taxonomy->name . ']',
],
- ]
+ ],
);
$output .= ' ';
}
@@ -212,7 +212,7 @@ public function generate_roles_permission_list() {
'checked' => $role->has_cap( 'copy_posts' ),
'label' => \translate_user_role( $display_name ),
],
- ]
+ ],
);
}
}
@@ -246,7 +246,7 @@ public function generate_post_types_list() {
'checked' => $this->is_post_type_enabled( $post_type_object->name ),
'label' => $post_type_object->labels->name,
],
- ]
+ ],
);
}
diff --git a/src/admin/options-inputs.php b/src/admin/options-inputs.php
index ad59b4963..306725319 100644
--- a/src/admin/options-inputs.php
+++ b/src/admin/options-inputs.php
@@ -25,7 +25,7 @@ protected function input( $type, $name, $value, $id, $attributes = '' ) {
\esc_attr( $name ),
\esc_attr( $id ),
\esc_attr( $value ),
- $attributes
+ $attributes,
);
}
diff --git a/src/admin/options-page.php b/src/admin/options-page.php
index 11af362ee..c20486c1c 100644
--- a/src/admin/options-page.php
+++ b/src/admin/options-page.php
@@ -77,7 +77,7 @@ public function register_menu() {
\__( 'Duplicate Post', 'duplicate-post' ),
'manage_options',
'duplicatepost',
- [ $this, 'generate_page' ]
+ [ $this, 'generate_page' ],
);
\add_action( $page_hook, [ $this, 'enqueue_assets' ] );
diff --git a/src/admin/options.php b/src/admin/options.php
index 7ed50bbc6..995ac18c8 100644
--- a/src/admin/options.php
+++ b/src/admin/options.php
@@ -37,7 +37,7 @@ public function get_options_for_tab( $tab, $fieldset = '' ) {
$options,
static function ( $option ) use ( $tab ) {
return \array_key_exists( 'tab', $option ) && $option['tab'] === $tab;
- }
+ },
);
if ( empty( $options ) ) {
@@ -50,7 +50,7 @@ static function ( $option ) use ( $tab ) {
$options,
static function ( $option ) use ( $fieldset ) {
return \array_key_exists( 'fieldset', $option ) && $option['fieldset'] === $fieldset;
- }
+ },
);
}
@@ -233,10 +233,10 @@ public function get_options() {
'tab' => 'display',
'fieldset' => 'show-original',
'type' => 'checkbox',
- 'label' => \__( 'In a metabox in the Edit screen', 'duplicate-post' ),
+ 'label' => \__( 'In a sidebar panel or in a metabox in the Edit screen', 'duplicate-post' ),
'value' => 1,
'description' => [
- \__( "You'll also be able to delete the reference to the original item with a checkbox", 'duplicate-post' ),
+ \__( "You'll also be able to delete the reference to the original item", 'duplicate-post' ),
],
],
'duplicate_post_show_original_column' => [
diff --git a/src/admin/views/options.php b/src/admin/views/options.php
index 714127c2c..a1652ac27 100644
--- a/src/admin/views/options.php
+++ b/src/admin/views/options.php
@@ -17,8 +17,12 @@
diff --git a/src/handlers/bulk-handler.php b/src/handlers/bulk-handler.php
index 648ab88d5..9cee6d208 100644
--- a/src/handlers/bulk-handler.php
+++ b/src/handlers/bulk-handler.php
@@ -89,18 +89,28 @@ public function rewrite_bulk_action_handler( $redirect_to, $doaction, $post_ids
}
$counter = 0;
+ $skipped = 0;
if ( \is_array( $post_ids ) ) {
foreach ( $post_ids as $post_id ) {
$post = \get_post( $post_id );
- if ( ! empty( $post ) && $this->permissions_helper->should_rewrite_and_republish_be_allowed( $post ) ) {
- $new_post_id = $this->post_duplicator->create_duplicate_for_rewrite_and_republish( $post );
- if ( ! \is_wp_error( $new_post_id ) ) {
- ++$counter;
- }
+ if ( empty( $post ) || ! $this->permissions_helper->should_rewrite_and_republish_be_allowed( $post ) ) {
+ continue;
+ }
+ if ( ! \current_user_can( 'edit_post', $post_id ) ) {
+ ++$skipped;
+ continue;
+ }
+ $new_post_id = $this->post_duplicator->create_duplicate_for_rewrite_and_republish( $post );
+ if ( ! \is_wp_error( $new_post_id ) ) {
+ ++$counter;
}
}
}
- return \add_query_arg( 'bulk_rewriting', $counter, $redirect_to );
+ $redirect_to = \add_query_arg( 'bulk_rewriting', $counter, $redirect_to );
+ if ( $skipped > 0 ) {
+ $redirect_to = \add_query_arg( 'bulk_rewriting_skipped', $skipped, $redirect_to );
+ }
+ return $redirect_to;
}
/**
@@ -118,21 +128,32 @@ public function clone_bulk_action_handler( $redirect_to, $doaction, $post_ids )
}
$counter = 0;
+ $skipped = 0;
if ( \is_array( $post_ids ) ) {
foreach ( $post_ids as $post_id ) {
$post = \get_post( $post_id );
- if ( ! empty( $post ) && ! $this->permissions_helper->is_rewrite_and_republish_copy( $post ) ) {
- if ( \intval( \get_option( 'duplicate_post_copychildren' ) !== 1 )
- || ! \is_post_type_hierarchical( $post->post_type )
- || ( \is_post_type_hierarchical( $post->post_type ) && ! Utils::has_ancestors_marked( $post, $post_ids ) )
- ) {
- if ( ! \is_wp_error( \duplicate_post_create_duplicate( $post ) ) ) {
- ++$counter;
- }
- }
+ if ( empty( $post ) || $this->permissions_helper->is_rewrite_and_republish_copy( $post ) ) {
+ continue;
+ }
+ if ( (int) \get_option( 'duplicate_post_copychildren' ) === 1
+ && \is_post_type_hierarchical( $post->post_type )
+ && Utils::has_ancestors_marked( $post, $post_ids )
+ ) {
+ continue;
+ }
+ if ( ! \current_user_can( 'edit_post', $post_id ) ) {
+ ++$skipped;
+ continue;
+ }
+ if ( ! \is_wp_error( \duplicate_post_create_duplicate( $post ) ) ) {
+ ++$counter;
}
}
}
- return \add_query_arg( 'bulk_cloned', $counter, $redirect_to );
+ $redirect_to = \add_query_arg( 'bulk_cloned', $counter, $redirect_to );
+ if ( $skipped > 0 ) {
+ $redirect_to = \add_query_arg( 'bulk_cloned_skipped', $skipped, $redirect_to );
+ }
+ return $redirect_to;
}
}
diff --git a/src/handlers/check-changes-handler.php b/src/handlers/check-changes-handler.php
index 5837b9490..23e3bc764 100644
--- a/src/handlers/check-changes-handler.php
+++ b/src/handlers/check-changes-handler.php
@@ -65,7 +65,7 @@ public function check_changes_action_handler() {
if ( ! ( isset( $_GET['post'] ) || isset( $_POST['post'] )
|| ( isset( $_REQUEST['action'] ) && $_REQUEST['action'] === 'duplicate_post_check_changes' ) ) ) {
\wp_die(
- \esc_html__( 'No post has been supplied!', 'duplicate-post' )
+ \esc_html__( 'No post has been supplied!', 'duplicate-post' ),
);
return;
}
@@ -82,9 +82,9 @@ public function check_changes_action_handler() {
\sprintf(
/* translators: %s: post ID. */
\__( 'Changes overview failed, could not find post with ID %s.', 'duplicate-post' ),
- $id
- )
- )
+ $id,
+ ),
+ ),
);
return;
}
@@ -94,8 +94,8 @@ public function check_changes_action_handler() {
if ( ! $this->original ) {
\wp_die(
\esc_html(
- \__( 'Changes overview failed, could not find original post.', 'duplicate-post' )
- )
+ \__( 'Changes overview failed, could not find original post.', 'duplicate-post' ),
+ ),
);
return;
}
@@ -106,10 +106,10 @@ public function check_changes_action_handler() {
diff --git a/src/handlers/handler.php b/src/handlers/handler.php
index c579e3471..c9aeb37a9 100644
--- a/src/handlers/handler.php
+++ b/src/handlers/handler.php
@@ -54,6 +54,13 @@ class Handler {
*/
protected $check_handler;
+ /**
+ * The REST API handler.
+ *
+ * @var Rest_API_Handler
+ */
+ protected $rest_api_handler;
+
/**
* Initializes the class.
*
@@ -68,10 +75,12 @@ public function __construct( Post_Duplicator $post_duplicator, Permissions_Helpe
$this->link_handler = new Link_Handler( $this->post_duplicator, $this->permissions_helper );
$this->check_handler = new Check_Changes_Handler( $this->permissions_helper );
$this->save_post_handler = new Save_Post_Handler( $this->permissions_helper );
+ $this->rest_api_handler = new Rest_API_Handler( $this->permissions_helper );
$this->bulk_handler->register_hooks();
$this->link_handler->register_hooks();
$this->check_handler->register_hooks();
$this->save_post_handler->register_hooks();
+ $this->rest_api_handler->register_hooks();
}
}
diff --git a/src/handlers/link-handler.php b/src/handlers/link-handler.php
index c1a06679f..72bd3eef8 100644
--- a/src/handlers/link-handler.php
+++ b/src/handlers/link-handler.php
@@ -73,14 +73,14 @@ public function new_draft_link_action_handler() {
\wp_die(
\esc_html(
\__( 'Copy creation failed, could not find original:', 'duplicate-post' ) . ' '
- . $id
- )
+ . $id,
+ ),
);
}
if ( $this->permissions_helper->is_rewrite_and_republish_copy( $post ) ) {
\wp_die(
- \esc_html__( 'You cannot create a copy of a post which is intended for Rewrite & Republish.', 'duplicate-post' )
+ \esc_html__( 'You cannot create a copy of a post which is intended for Rewrite & Republish.', 'duplicate-post' ),
);
}
@@ -88,7 +88,7 @@ public function new_draft_link_action_handler() {
if ( \is_wp_error( $new_id ) ) {
\wp_die(
- \esc_html__( 'Copy creation failed, could not create a copy.', 'duplicate-post' )
+ \esc_html__( 'Copy creation failed, could not create a copy.', 'duplicate-post' ),
);
}
@@ -98,8 +98,8 @@ public function new_draft_link_action_handler() {
'cloned' => 1,
'ids' => $post->ID,
],
- \admin_url( 'post.php?action=edit&post=' . $new_id . ( isset( $_GET['classic-editor'] ) ? '&classic-editor' : '' ) )
- )
+ \admin_url( 'post.php?action=edit&post=' . $new_id . ( isset( $_GET['classic-editor'] ) ? '&classic-editor' : '' ) ),
+ ),
);
exit();
}
@@ -129,14 +129,14 @@ public function clone_link_action_handler() {
\wp_die(
\esc_html(
\__( 'Copy creation failed, could not find original:', 'duplicate-post' ) . ' '
- . $id
- )
+ . $id,
+ ),
);
}
if ( $this->permissions_helper->is_rewrite_and_republish_copy( $post ) ) {
\wp_die(
- \esc_html__( 'You cannot create a copy of a post which is intended for Rewrite & Republish.', 'duplicate-post' )
+ \esc_html__( 'You cannot create a copy of a post which is intended for Rewrite & Republish.', 'duplicate-post' ),
);
}
@@ -144,7 +144,7 @@ public function clone_link_action_handler() {
if ( \is_wp_error( $new_id ) ) {
\wp_die(
- \esc_html__( 'Copy creation failed, could not create a copy.', 'duplicate-post' )
+ \esc_html__( 'Copy creation failed, could not create a copy.', 'duplicate-post' ),
);
}
@@ -172,8 +172,8 @@ public function clone_link_action_handler() {
'cloned' => 1,
'ids' => $post->ID,
],
- $sendback
- )
+ $sendback,
+ ),
);
exit();
}
@@ -203,14 +203,14 @@ public function rewrite_link_action_handler() {
\wp_die(
\esc_html(
\__( 'Copy creation failed, could not find original:', 'duplicate-post' ) . ' '
- . $id
- )
+ . $id,
+ ),
);
}
if ( ! $this->permissions_helper->should_rewrite_and_republish_be_allowed( $post ) ) {
\wp_die(
- \esc_html__( 'You cannot create a copy for Rewrite & Republish if the original is not published or if it already has a copy.', 'duplicate-post' )
+ \esc_html__( 'You cannot create a copy for Rewrite & Republish if the original is not published or if it already has a copy.', 'duplicate-post' ),
);
}
@@ -218,7 +218,7 @@ public function rewrite_link_action_handler() {
if ( \is_wp_error( $new_id ) ) {
\wp_die(
- \esc_html__( 'Copy creation failed, could not create a copy.', 'duplicate-post' )
+ \esc_html__( 'Copy creation failed, could not create a copy.', 'duplicate-post' ),
);
}
@@ -228,8 +228,8 @@ public function rewrite_link_action_handler() {
'rewriting' => 1,
'ids' => $post->ID,
],
- \admin_url( 'post.php?action=edit&post=' . $new_id . ( isset( $_GET['classic-editor'] ) ? '&classic-editor' : '' ) )
- )
+ \admin_url( 'post.php?action=edit&post=' . $new_id . ( isset( $_GET['classic-editor'] ) ? '&classic-editor' : '' ) ),
+ ),
);
exit();
}
diff --git a/src/handlers/rest-api-handler.php b/src/handlers/rest-api-handler.php
new file mode 100644
index 000000000..a70bdd438
--- /dev/null
+++ b/src/handlers/rest-api-handler.php
@@ -0,0 +1,144 @@
+permissions_helper = $permissions_helper;
+ }
+
+ /**
+ * Adds hooks to integrate with WordPress.
+ *
+ * @return void
+ */
+ public function register_hooks() {
+ \add_action( 'rest_api_init', [ $this, 'register_routes' ] );
+ }
+
+ /**
+ * Registers the REST API routes.
+ *
+ * @return void
+ */
+ public function register_routes() {
+ \register_rest_route(
+ self::REST_NAMESPACE,
+ '/original/(?P
\d+)',
+ [
+ 'methods' => WP_REST_Server::DELETABLE,
+ 'callback' => [ $this, 'remove_original' ],
+ 'permission_callback' => [ $this, 'can_remove_original' ],
+ 'args' => [
+ 'post_id' => [
+ 'description' => \__( 'The ID of the post to remove the original reference from.', 'duplicate-post' ),
+ 'type' => 'integer',
+ 'required' => true,
+ 'validate_callback' => static function ( $param ) {
+ return \is_numeric( $param ) && (int) $param > 0;
+ },
+ 'sanitize_callback' => 'absint',
+ ],
+ ],
+ ],
+ );
+ }
+
+ /**
+ * Checks if the current user can remove the original reference.
+ *
+ * @param WP_REST_Request $request The REST request object.
+ *
+ * @return bool|WP_Error True if the user can remove the original, WP_Error otherwise.
+ */
+ public function can_remove_original( WP_REST_Request $request ) {
+ $post_id = $request->get_param( 'post_id' );
+ $post = \get_post( $post_id );
+
+ if ( ! $post ) {
+ return new WP_Error(
+ 'rest_post_not_found',
+ \__( 'Post not found.', 'duplicate-post' ),
+ [ 'status' => 404 ],
+ );
+ }
+
+ if ( ! \current_user_can( 'edit_post', $post_id ) ) {
+ return new WP_Error(
+ 'rest_forbidden',
+ \__( 'You do not have permission to edit this post.', 'duplicate-post' ),
+ [ 'status' => 403 ],
+ );
+ }
+
+ if ( $this->permissions_helper->is_rewrite_and_republish_copy( $post ) ) {
+ return new WP_Error(
+ 'rest_forbidden',
+ \__( 'Cannot remove original reference from a Rewrite & Republish copy.', 'duplicate-post' ),
+ [ 'status' => 403 ],
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Removes the original reference from a post.
+ *
+ * @param WP_REST_Request $request The REST request object.
+ *
+ * @return WP_REST_Response|WP_Error The REST response or error.
+ */
+ public function remove_original( WP_REST_Request $request ) {
+ $post_id = $request->get_param( 'post_id' );
+
+ $deleted = \delete_post_meta( $post_id, '_dp_original' );
+
+ if ( ! $deleted ) {
+ return new WP_Error(
+ 'rest_cannot_delete',
+ \__( 'Could not remove the original reference.', 'duplicate-post' ),
+ [ 'status' => 500 ],
+ );
+ }
+
+ return new WP_REST_Response(
+ [
+ 'success' => true,
+ 'message' => \__( 'Original reference removed successfully.', 'duplicate-post' ),
+ ],
+ 200,
+ );
+ }
+}
diff --git a/src/handlers/save-post-handler.php b/src/handlers/save-post-handler.php
index 800f7c43d..997ff977a 100644
--- a/src/handlers/save-post-handler.php
+++ b/src/handlers/save-post-handler.php
@@ -33,8 +33,9 @@ public function __construct( Permissions_Helper $permissions_helper ) {
* @return void
*/
public function register_hooks() {
- if ( \intval( \get_option( 'duplicate_post_show_original_meta_box' ) ) === 1
- || \intval( \get_option( 'duplicate_post_show_original_column' ) ) === 1 ) {
+ if ( (int) \get_option( 'duplicate_post_show_original_meta_box' ) === 1
+ || (int) \get_option( 'duplicate_post_show_original_column' ) === 1
+ ) {
\add_action( 'save_post', [ $this, 'delete_on_save_post' ] );
}
}
@@ -42,14 +43,18 @@ public function register_hooks() {
/**
* Deletes the custom field with the ID of the original post.
*
+ * Handles the classic editor checkbox for removing the original reference.
+ *
* @param int $post_id The current post ID.
*
* @return void
*/
public function delete_on_save_post( $post_id ) {
- if ( ( \defined( 'DOING_AUTOSAVE' ) && \DOING_AUTOSAVE )
- || empty( $_POST['duplicate_post_remove_original'] )
- || ! \current_user_can( 'edit_post', $post_id ) ) {
+ if ( \defined( 'DOING_AUTOSAVE' ) && \DOING_AUTOSAVE ) {
+ return;
+ }
+
+ if ( ! \current_user_can( 'edit_post', $post_id ) ) {
return;
}
@@ -57,7 +62,14 @@ public function delete_on_save_post( $post_id ) {
if ( ! $post ) {
return;
}
- if ( ! $this->permissions_helper->is_rewrite_and_republish_copy( $post ) ) {
+
+ if ( $this->permissions_helper->is_rewrite_and_republish_copy( $post ) ) {
+ return;
+ }
+
+ // Check for classic editor (POST request).
+ // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce is verified in the metabox.
+ if ( ! empty( $_POST['duplicate_post_remove_original'] ) ) {
\delete_post_meta( $post_id, '_dp_original' );
}
}
diff --git a/src/permissions-helper.php b/src/permissions-helper.php
index 8f40d0e4e..686ee7e0d 100644
--- a/src/permissions-helper.php
+++ b/src/permissions-helper.php
@@ -64,7 +64,7 @@ public function is_current_user_allowed_to_copy() {
* @return bool Whether the post is a copy intended for Rewrite & Republish.
*/
public function is_rewrite_and_republish_copy( WP_Post $post ) {
- return ( \intval( \get_post_meta( $post->ID, '_dp_is_rewrite_republish_copy', true ) ) === 1 );
+ return ( (int) \get_post_meta( $post->ID, '_dp_is_rewrite_republish_copy', true ) === 1 );
}
/**
diff --git a/src/post-duplicator.php b/src/post-duplicator.php
index c61718d51..5f12cc572 100644
--- a/src/post-duplicator.php
+++ b/src/post-duplicator.php
@@ -15,7 +15,7 @@ class Post_Duplicator {
/**
* Returns an array with the default option values.
*
- * @return array The default options values.
+ * @return array The default options values.
*/
public function get_default_options() {
return [
@@ -71,7 +71,7 @@ public function create_duplicate( WP_Post $post, array $options = [] ) {
}
if ( ! empty( $options['increase_menu_order_by'] ) && \is_numeric( $options['increase_menu_order_by'] ) ) {
- $menu_order += \intval( $options['increase_menu_order_by'] );
+ $menu_order += (int) $options['increase_menu_order_by'];
}
$new_post = [
@@ -187,7 +187,7 @@ public function create_duplicate_for_rewrite_and_republish( WP_Post $post ) {
*/
public function copy_post_taxonomies( $new_id, $post, $options ) {
// Clear default category (added by wp_insert_post).
- \wp_set_object_terms( $new_id, null, 'category' );
+ \wp_set_object_terms( $new_id, [], 'category' );
$post_taxonomies = \get_object_taxonomies( $post->post_type );
// Several plugins just add support to post-formats but don't register post_format taxonomy.
diff --git a/src/post-republisher.php b/src/post-republisher.php
index eca6ee3c3..a2b2c22b1 100644
--- a/src/post-republisher.php
+++ b/src/post-republisher.php
@@ -63,6 +63,8 @@ public function register_hooks() {
// Clean up after the redirect to the original post.
\add_action( 'load-post.php', [ $this, 'clean_up_after_redirect' ] );
+ // Clean up orphaned R&R copies when opening a post for editing.
+ \add_action( 'load-post.php', [ $this, 'clean_up_orphaned_copy' ], 11 );
// Clean up the original when the copy is manually deleted from the trash.
\add_action( 'before_delete_post', [ $this, 'clean_up_when_copy_manually_deleted' ] );
// Ensure scheduled Rewrite and Republish posts are properly handled.
@@ -140,6 +142,14 @@ public function republish_request( $post ) {
return;
}
+ if ( ! \current_user_can( 'edit_post', $original_post->ID ) ) {
+ \wp_die(
+ \esc_html__( 'You are not allowed to republish this post.', 'duplicate-post' ),
+ \esc_html__( 'Permission denied', 'duplicate-post' ),
+ [ 'response' => 403 ],
+ );
+ }
+
$this->republish( $post, $original_post );
// Trigger the redirect in the Classic Editor.
@@ -205,6 +215,39 @@ public function republish_scheduled_post( $copy ) {
$this->delete_copy( $copy->ID, $original_post->ID );
}
+ /**
+ * Cleans up orphaned Rewrite & Republish copies when opening a post for editing.
+ *
+ * This ensures that if a copy is stuck in the dp-rewrite-republish status,
+ * it gets deleted automatically to unblock the R&R functionality.
+ *
+ * @return void
+ */
+ public function clean_up_orphaned_copy() {
+ if ( empty( $_GET['post'] ) || empty( $_GET['action'] ) || $_GET['action'] !== 'edit' ) {
+ return;
+ }
+
+ $post_id = \intval( \wp_unslash( $_GET['post'] ) );
+ $post = \get_post( $post_id );
+
+ if ( ! $post || $this->permissions_helper->is_rewrite_and_republish_copy( $post ) ) {
+ return;
+ }
+
+ // Check if this post has an orphaned R&R copy.
+ $copy = $this->permissions_helper->get_rewrite_and_republish_copy( $post );
+
+ if ( ! $copy ) {
+ return;
+ }
+
+ // If the copy is in dp-rewrite-republish status, it's orphaned and should be deleted.
+ if ( $copy->post_status === 'dp-rewrite-republish' ) {
+ $this->delete_copy( $copy->ID, $post->ID );
+ }
+ }
+
/**
* Cleans up the copied post and temporary metadata after the user has been redirected.
*
@@ -217,7 +260,7 @@ public function clean_up_after_redirect() {
\check_admin_referer( 'dp-republish', 'dpnonce' );
- if ( \intval( \get_post_meta( $copy_id, '_dp_has_been_republished', true ) ) === 1 ) {
+ if ( (int) \get_post_meta( $copy_id, '_dp_has_been_republished', true ) === 1 ) {
$this->delete_copy( $copy_id, $post_id );
}
else {
@@ -257,6 +300,21 @@ public function is_rest_request() {
* @return void
*/
public function republish( WP_Post $post, WP_Post $original_post ) {
+
+ /**
+ * Fires before the Rewrite & Republish copy is republished to the original post.
+ *
+ * This action runs before any content, taxonomies, or meta are copied from the
+ * Rewrite & Republish copy to the original post. Use this hook to perform actions
+ * or modifications before the republishing process begins.
+ *
+ * @since 4.6
+ *
+ * @param WP_Post $post The Rewrite & Republish copy.
+ * @param WP_Post $original_post The original post that will be overwritten.
+ */
+ \do_action( 'duplicate_post_before_republish', $post, $original_post );
+
// Remove WordPress default filter so a new revision is not created on republish.
\remove_action( 'post_updated', 'wp_save_post_revision', 10 );
@@ -272,6 +330,21 @@ public function republish( WP_Post $post, WP_Post $original_post ) {
// Re-enable the creation of a new revision.
\add_action( 'post_updated', 'wp_save_post_revision', 10, 1 );
+
+ /**
+ * Fires after the Rewrite & Republish copy has been republished to the original post.
+ *
+ * This action runs after all content, taxonomies, and meta have been copied from
+ * the Rewrite & Republish copy to the original post. The copy is marked as republished
+ * but has not yet been deleted. Use this hook to perform cleanup or additional
+ * processing after the republishing is complete.
+ *
+ * @since 4.6
+ *
+ * @param WP_Post $post The Rewrite & Republish copy.
+ * @param WP_Post $original_post The original post that has been updated.
+ */
+ \do_action( 'duplicate_post_after_republish', $post, $original_post );
}
/**
@@ -295,7 +368,7 @@ public function delete_copy( $copy_id, $post_id = null, $permanently_delete = tr
// Delete the copy bypassing the trash so it also deletes the copy post meta.
\wp_delete_post( $copy_id, $permanently_delete );
- if ( ! \is_null( $post_id ) ) {
+ if ( $post_id !== null ) {
// Delete the meta that marks the original post has having a copy.
\delete_post_meta( $post_id, '_dp_has_rewrite_republish_copy' );
}
@@ -386,8 +459,8 @@ protected function redirect( $original_post_id, $copy_id ) {
'dpcopy' => $copy_id,
'dpnonce' => \wp_create_nonce( 'dp-republish' ),
],
- \admin_url( 'post.php?action=edit&post=' . $original_post_id )
- )
+ \admin_url( 'post.php?action=edit&post=' . $original_post_id ),
+ ),
);
exit();
}
diff --git a/src/revisions-migrator.php b/src/revisions-migrator.php
index 448e06dec..dcd3d8ef8 100644
--- a/src/revisions-migrator.php
+++ b/src/revisions-migrator.php
@@ -33,7 +33,7 @@ public function migrate_revisions( $copy_id, $original_id ) {
$copy = \get_post( $copy_id );
$original_post = \get_post( $original_id );
- if ( \is_null( $copy ) || \is_null( $original_post ) || ! \wp_revisions_enabled( $original_post ) ) {
+ if ( $copy === null || $original_post === null || ! \wp_revisions_enabled( $original_post ) ) {
return;
}
diff --git a/src/ui/admin-bar.php b/src/ui/admin-bar.php
index 18369128c..5454141fb 100644
--- a/src/ui/admin-bar.php
+++ b/src/ui/admin-bar.php
@@ -51,7 +51,7 @@ public function __construct( Link_Builder $link_builder, Permissions_Helper $per
* @return void
*/
public function register_hooks() {
- if ( \intval( Utils::get_option( 'duplicate_post_show_link_in', 'adminbar' ) ) === 1 ) {
+ if ( (int) Utils::get_option( 'duplicate_post_show_link_in', 'adminbar' ) === 1 ) {
\add_action( 'wp_before_admin_bar_render', [ $this, 'admin_bar_render' ] );
\add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_styles' ] );
\add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_styles' ] );
@@ -78,8 +78,8 @@ public function admin_bar_render() {
return;
}
- $show_new_draft = ( \intval( Utils::get_option( 'duplicate_post_show_link', 'new_draft' ) ) === 1 );
- $show_rewrite_and_republish = ( \intval( Utils::get_option( 'duplicate_post_show_link', 'rewrite_republish' ) ) === 1 )
+ $show_new_draft = ( (int) Utils::get_option( 'duplicate_post_show_link', 'new_draft' ) === 1 );
+ $show_rewrite_and_republish = ( (int) Utils::get_option( 'duplicate_post_show_link', 'rewrite_republish' ) === 1 )
&& $this->permissions_helper->should_rewrite_and_republish_be_allowed( $post );
if ( $show_new_draft && $show_rewrite_and_republish ) {
@@ -88,7 +88,7 @@ public function admin_bar_render() {
'id' => 'duplicate-post',
'title' => '' . \__( 'Duplicate Post', 'duplicate-post' ) . ' ',
'href' => $this->link_builder->build_new_draft_link( $post ),
- ]
+ ],
);
$wp_admin_bar->add_menu(
[
@@ -96,7 +96,7 @@ public function admin_bar_render() {
'parent' => 'duplicate-post',
'title' => \__( 'Copy to a new draft', 'duplicate-post' ),
'href' => $this->link_builder->build_new_draft_link( $post ),
- ]
+ ],
);
$wp_admin_bar->add_menu(
[
@@ -104,7 +104,7 @@ public function admin_bar_render() {
'parent' => 'duplicate-post',
'title' => \__( 'Rewrite & Republish', 'duplicate-post' ),
'href' => $this->link_builder->build_rewrite_and_republish_link( $post ),
- ]
+ ],
);
}
else {
@@ -114,7 +114,7 @@ public function admin_bar_render() {
'id' => 'new-draft',
'title' => '' . \__( 'Copy to a new draft', 'duplicate-post' ) . ' ',
'href' => $this->link_builder->build_new_draft_link( $post ),
- ]
+ ],
);
}
@@ -124,7 +124,7 @@ public function admin_bar_render() {
'id' => 'rewrite-republish',
'title' => '' . \__( 'Rewrite & Republish', 'duplicate-post' ) . ' ',
'href' => $this->link_builder->build_rewrite_and_republish_link( $post ),
- ]
+ ],
);
}
}
diff --git a/src/ui/asset-manager.php b/src/ui/asset-manager.php
index 38e930b45..b9c3b9e1d 100644
--- a/src/ui/asset-manager.php
+++ b/src/ui/asset-manager.php
@@ -2,8 +2,6 @@
namespace Yoast\WP\Duplicate_Post\UI;
-use Yoast\WP\Duplicate_Post\Utils;
-
/**
* Duplicate Post class to manage assets.
*/
@@ -35,46 +33,47 @@ public function register_styles() {
* @return void
*/
public function register_scripts() {
- $flattened_version = Utils::flatten_version( \DUPLICATE_POST_CURRENT_VERSION );
-
\wp_register_script(
'duplicate_post_edit_script',
- \plugins_url( \sprintf( 'js/dist/duplicate-post-edit-%s.js', $flattened_version ), \DUPLICATE_POST_FILE ),
+ \plugins_url( 'js/dist/duplicate-post-edit.js', \DUPLICATE_POST_FILE ),
[
+ 'wp-api-fetch',
'wp-components',
'wp-element',
'wp-i18n',
],
\DUPLICATE_POST_CURRENT_VERSION,
- true
+ true,
);
+ \wp_set_script_translations( 'duplicate_post_edit_script', 'duplicate-post' );
\wp_register_script(
'duplicate_post_strings',
- \plugins_url( \sprintf( 'js/dist/duplicate-post-strings-%s.js', $flattened_version ), \DUPLICATE_POST_FILE ),
+ \plugins_url( 'js/dist/duplicate-post-strings.js', \DUPLICATE_POST_FILE ),
[
'wp-components',
'wp-element',
'wp-i18n',
],
\DUPLICATE_POST_CURRENT_VERSION,
- true
+ true,
);
+ \wp_set_script_translations( 'duplicate_post_strings', 'duplicate-post' );
\wp_register_script(
'duplicate_post_quick_edit_script',
- \plugins_url( \sprintf( 'js/dist/duplicate-post-quick-edit-%s.js', $flattened_version ), \DUPLICATE_POST_FILE ),
+ \plugins_url( 'js/dist/duplicate-post-quick-edit.js', \DUPLICATE_POST_FILE ),
[ 'jquery' ],
\DUPLICATE_POST_CURRENT_VERSION,
- true
+ true,
);
\wp_register_script(
'duplicate_post_options_script',
- \plugins_url( \sprintf( 'js/dist/duplicate-post-options-%s.js', $flattened_version ), \DUPLICATE_POST_FILE ),
+ \plugins_url( 'js/dist/duplicate-post-options.js', \DUPLICATE_POST_FILE ),
[ 'jquery' ],
\DUPLICATE_POST_CURRENT_VERSION,
- true
+ true,
);
}
@@ -109,12 +108,12 @@ public function enqueue_edit_script( $data_object = [] ) {
\wp_add_inline_script(
$handle,
'let duplicatePostNotices = {};',
- 'before'
+ 'before',
);
\wp_localize_script(
$handle,
'duplicatePost',
- $data_object
+ $data_object,
);
}
@@ -131,7 +130,7 @@ public function enqueue_strings_script( $data_object = [] ) {
\wp_localize_script(
$handle,
'duplicatePostStrings',
- $data_object
+ $data_object,
);
}
@@ -161,21 +160,20 @@ public function enqueue_options_script() {
* @return void
*/
public function enqueue_elementor_script( $data_object = [] ) {
- $flattened_version = Utils::flatten_version( \DUPLICATE_POST_CURRENT_VERSION );
- $handle = 'duplicate_post_elementor_script';
+ $handle = 'duplicate_post_elementor_script';
\wp_register_script(
$handle,
- \plugins_url( \sprintf( 'js/dist/duplicate-post-elementor-%s.js', $flattened_version ), \DUPLICATE_POST_FILE ),
+ \plugins_url( 'js/dist/duplicate-post-elementor.js', \DUPLICATE_POST_FILE ),
[ 'jquery' ],
\DUPLICATE_POST_CURRENT_VERSION,
- true
+ true,
);
\wp_enqueue_script( $handle );
\wp_localize_script(
$handle,
'duplicatePost',
- $data_object
+ $data_object,
);
}
}
diff --git a/src/ui/block-editor.php b/src/ui/block-editor.php
index db6b4243f..f2b0aeae9 100644
--- a/src/ui/block-editor.php
+++ b/src/ui/block-editor.php
@@ -87,7 +87,7 @@ public function hide_elementor_post_status() {
}
\wp_add_inline_style(
'elementor-editor',
- '.elementor-control-post_status { display: none !important; }'
+ '.elementor-control-post_status { display: none !important; }',
);
}
@@ -129,6 +129,8 @@ public function enqueue_block_editor_scripts() {
return;
}
+ $this->asset_manager->enqueue_styles();
+
$edit_js_object = $this->generate_js_object( $post );
$this->asset_manager->enqueue_edit_script( $edit_js_object );
@@ -214,7 +216,7 @@ public function get_original_post_edit_url() {
'dpcopy' => $post->ID,
'dpnonce' => \wp_create_nonce( 'dp-republish' ),
],
- \admin_url( 'post.php?action=edit&post=' . $original_post_id )
+ \admin_url( 'post.php?action=edit&post=' . $original_post_id ),
);
}
@@ -223,18 +225,32 @@ public function get_original_post_edit_url() {
*
* @param WP_Post $post The current post object.
*
- * @return array The data to pass to JavaScript.
+ * @return array The data to pass to JavaScript.
*/
protected function generate_js_object( WP_Post $post ) {
$is_rewrite_and_republish_copy = $this->permissions_helper->is_rewrite_and_republish_copy( $post );
+ $original_item = Utils::get_original( $post );
+ $original_data = null;
+
+ if ( $original_item instanceof WP_Post ) {
+ $original_data = [
+ 'editUrl' => \esc_url_raw( \get_edit_post_link( $original_item->ID, 'raw' ) ),
+ 'viewUrl' => \esc_url_raw( \get_permalink( $original_item->ID ) ),
+ 'title' => \html_entity_decode( \_draft_or_post_title( $original_item ), \ENT_QUOTES, 'UTF-8' ),
+ 'canEdit' => \current_user_can( 'edit_post', $original_item->ID ),
+ ];
+ }
return [
+ 'postId' => $post->ID,
'newDraftLink' => $this->get_new_draft_permalink(),
'rewriteAndRepublishLink' => $this->get_rewrite_republish_permalink(),
'showLinks' => Utils::get_option( 'duplicate_post_show_link' ),
'showLinksIn' => Utils::get_option( 'duplicate_post_show_link_in' ),
'rewriting' => ( $is_rewrite_and_republish_copy ) ? 1 : 0,
'originalEditURL' => $this->get_original_post_edit_url(),
+ 'showOriginalMetaBox' => (int) \get_option( 'duplicate_post_show_original_meta_box' ) === 1,
+ 'originalItem' => $original_data,
];
}
@@ -268,7 +284,7 @@ public function remove_original_from_wpseo_link_suggestions( $suggestions, $obje
$suggestions,
static function ( $suggestion ) use ( $original_post_id ) {
return $suggestion->object_id !== $original_post_id;
- }
+ },
);
}
}
diff --git a/src/ui/bulk-actions.php b/src/ui/bulk-actions.php
index 28450b76c..106745e06 100644
--- a/src/ui/bulk-actions.php
+++ b/src/ui/bulk-actions.php
@@ -32,7 +32,7 @@ public function __construct( Permissions_Helper $permissions_helper ) {
* @return void
*/
public function register_hooks() {
- if ( \intval( Utils::get_option( 'duplicate_post_show_link_in', 'bulkactions' ) ) === 0 ) {
+ if ( (int) Utils::get_option( 'duplicate_post_show_link_in', 'bulkactions' ) === 0 ) {
return;
}
@@ -58,19 +58,20 @@ public function add_bulk_filters() {
/**
* Adds 'Rewrite & Republish' to the bulk action dropdown.
*
- * @param array $bulk_actions The bulk actions array.
+ * @param array $bulk_actions The bulk actions array.
*
- * @return array The bulk actions array.
+ * @return array The bulk actions array.
*/
public function register_bulk_action( $bulk_actions ) {
$is_draft_or_trash = isset( $_REQUEST['post_status'] ) && \in_array( $_REQUEST['post_status'], [ 'draft', 'trash' ], true );
- if ( \intval( Utils::get_option( 'duplicate_post_show_link', 'clone' ) ) === 1 ) {
+ if ( (int) Utils::get_option( 'duplicate_post_show_link', 'clone' ) === 1 ) {
$bulk_actions['duplicate_post_bulk_clone'] = \esc_html__( 'Clone', 'duplicate-post' );
}
if ( ! $is_draft_or_trash
- && \intval( Utils::get_option( 'duplicate_post_show_link', 'rewrite_republish' ) ) === 1 ) {
+ && (int) Utils::get_option( 'duplicate_post_show_link', 'rewrite_republish' ) === 1
+ ) {
$bulk_actions['duplicate_post_bulk_rewrite_republish'] = \esc_html__( 'Rewrite & Republish', 'duplicate-post' );
}
diff --git a/src/ui/classic-editor.php b/src/ui/classic-editor.php
index 7d5cb942c..868426596 100644
--- a/src/ui/classic-editor.php
+++ b/src/ui/classic-editor.php
@@ -53,12 +53,12 @@ public function __construct( Link_Builder $link_builder, Permissions_Helper $per
public function register_hooks() {
\add_action( 'post_submitbox_misc_actions', [ $this, 'add_check_changes_link' ], 90 );
- if ( \intval( Utils::get_option( 'duplicate_post_show_link_in', 'submitbox' ) ) === 1 ) {
- if ( \intval( Utils::get_option( 'duplicate_post_show_link', 'new_draft' ) ) === 1 ) {
+ if ( (int) Utils::get_option( 'duplicate_post_show_link_in', 'submitbox' ) === 1 ) {
+ if ( (int) Utils::get_option( 'duplicate_post_show_link', 'new_draft' ) === 1 ) {
\add_action( 'post_submitbox_start', [ $this, 'add_new_draft_post_button' ] );
}
- if ( \intval( Utils::get_option( 'duplicate_post_show_link', 'rewrite_republish' ) ) === 1 ) {
+ if ( (int) Utils::get_option( 'duplicate_post_show_link', 'rewrite_republish' ) === 1 ) {
\add_action( 'post_submitbox_start', [ $this, 'add_rewrite_and_republish_post_button' ] );
}
}
@@ -67,9 +67,10 @@ public function register_hooks() {
\add_filter( 'post_updated_messages', [ $this, 'change_scheduled_notice_classic_editor' ] );
\add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_classic_editor_scripts' ] );
- if ( \intval( Utils::get_option( 'duplicate_post_show_link_in', 'submitbox' ) ) === 1 ) {
- if ( \intval( Utils::get_option( 'duplicate_post_show_link', 'new_draft' ) ) === 1
- || \intval( Utils::get_option( 'duplicate_post_show_link', 'rewrite_republish' ) ) === 1 ) {
+ if ( (int) Utils::get_option( 'duplicate_post_show_link_in', 'submitbox' ) === 1 ) {
+ if ( (int) Utils::get_option( 'duplicate_post_show_link', 'new_draft' ) === 1
+ || (int) Utils::get_option( 'duplicate_post_show_link', 'rewrite_republish' ) === 1
+ ) {
\add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_classic_editor_styles' ] );
}
}
@@ -99,7 +100,7 @@ public function enqueue_classic_editor_scripts() {
$id = \intval( \wp_unslash( $_GET['post'] ) );
$post = \get_post( $id );
- if ( ! \is_null( $post ) && $this->permissions_helper->is_rewrite_and_republish_copy( $post ) ) {
+ if ( $post !== null && $this->permissions_helper->is_rewrite_and_republish_copy( $post ) ) {
$this->asset_manager->enqueue_strings_script();
}
}
@@ -116,7 +117,7 @@ public function enqueue_classic_editor_styles() {
$id = \intval( \wp_unslash( $_GET['post'] ) );
$post = \get_post( $id );
- if ( ! \is_null( $post ) && $this->permissions_helper->should_links_be_displayed( $post ) ) {
+ if ( $post !== null && $this->permissions_helper->should_links_be_displayed( $post ) ) {
$this->asset_manager->enqueue_styles();
}
}
@@ -130,7 +131,7 @@ public function enqueue_classic_editor_styles() {
* @return void
*/
public function add_new_draft_post_button( $post = null ) {
- if ( \is_null( $post ) ) {
+ if ( $post === null ) {
if ( isset( $_GET['post'] ) ) {
$id = \intval( \wp_unslash( $_GET['post'] ) );
$post = \get_post( $id );
@@ -156,7 +157,7 @@ public function add_new_draft_post_button( $post = null ) {
* @return void
*/
public function add_rewrite_and_republish_post_button( $post = null ) {
- if ( \is_null( $post ) ) {
+ if ( $post === null ) {
if ( isset( $_GET['post'] ) ) {
$id = \intval( \wp_unslash( $_GET['post'] ) );
$post = \get_post( $id );
@@ -185,7 +186,7 @@ public function add_rewrite_and_republish_post_button( $post = null ) {
* @return void
*/
public function add_check_changes_link( $post = null ) {
- if ( \is_null( $post ) ) {
+ if ( $post === null ) {
if ( isset( $_GET['post'] ) ) {
$id = \intval( \wp_unslash( $_GET['post'] ) );
$post = \get_post( $id );
@@ -258,9 +259,9 @@ public function change_schedule_strings_classic_editor( $translation, $text, $co
/**
* Changes the post-scheduled notice when a post or page intended for republishing is scheduled.
*
- * @param array[] $messages Post updated messaged.
+ * @param array> $messages Post updated messaged.
*
- * @return array[] The to-be-used messages.
+ * @return array> The to-be-used messages.
*/
public function change_scheduled_notice_classic_editor( $messages ) {
$post = \get_post();
@@ -277,10 +278,10 @@ public function change_scheduled_notice_classic_editor( $messages ) {
/* translators: 1: The post title with a link to the frontend page, 2: The scheduled date and time. */
\esc_html__(
'This rewritten post %1$s is now scheduled to replace the original post. It will be published on %2$s.',
- 'duplicate-post'
+ 'duplicate-post',
),
'' . $post->post_title . ' ',
- '' . $scheduled_date . ' ' . $scheduled_time . ' '
+ '' . $scheduled_date . ' ' . $scheduled_time . ' ',
);
return $messages;
}
@@ -290,10 +291,10 @@ public function change_scheduled_notice_classic_editor( $messages ) {
/* translators: 1: The page title with a link to the frontend page, 2: The scheduled date and time. */
\esc_html__(
'This rewritten page %1$s is now scheduled to replace the original page. It will be published on %2$s.',
- 'duplicate-post'
+ 'duplicate-post',
),
'' . $post->post_title . ' ',
- '' . $scheduled_date . ' ' . $scheduled_time . ' '
+ '' . $scheduled_date . ' ' . $scheduled_time . ' ',
);
}
diff --git a/src/ui/column.php b/src/ui/column.php
index a61806907..888e64b4c 100644
--- a/src/ui/column.php
+++ b/src/ui/column.php
@@ -42,14 +42,14 @@ public function __construct( Permissions_Helper $permissions_helper, Asset_Manag
* @return void
*/
public function register_hooks() {
- if ( \intval( \get_option( 'duplicate_post_show_original_column' ) ) === 1 ) {
+ if ( (int) \get_option( 'duplicate_post_show_original_column' ) === 1 ) {
$enabled_post_types = $this->permissions_helper->get_enabled_post_types();
if ( \count( $enabled_post_types ) ) {
foreach ( $enabled_post_types as $enabled_post_type ) {
\add_filter( "manage_{$enabled_post_type}_posts_columns", [ $this, 'add_original_column' ] );
\add_action( "manage_{$enabled_post_type}_posts_custom_column", [ $this, 'show_original_item' ], 10, 2 );
}
- \add_action( 'quick_edit_custom_box', [ $this, 'quick_edit_remove_original' ], 10, 2 );
+ \add_action( 'quick_edit_custom_box', [ $this, 'quick_edit_remove_original' ] );
\add_action( 'admin_enqueue_scripts', [ $this, 'admin_enqueue_scripts' ] );
\add_action( 'admin_enqueue_scripts', [ $this, 'admin_enqueue_styles' ] );
}
@@ -59,9 +59,9 @@ public function register_hooks() {
/**
* Adds Original item column to the post list.
*
- * @param array $post_columns The post columns array.
+ * @param array $post_columns The post columns array.
*
- * @return array The updated array.
+ * @return array The updated array.
*/
public function add_original_column( $post_columns ) {
if ( \is_array( $post_columns ) ) {
@@ -94,10 +94,10 @@ public function show_original_item( $column_name, $post_id ) {
$column_content = Utils::get_edit_or_view_link( $original_item );
}
- echo \sprintf(
+ \printf(
'%s ',
$data_attr, // phpcs:ignore WordPress.Security.EscapeOutput
- $column_content // phpcs:ignore WordPress.Security.EscapeOutput
+ $column_content, // phpcs:ignore WordPress.Security.EscapeOutput
);
}
}
@@ -129,19 +129,19 @@ public function quick_edit_remove_original( $column_name ) {
',
\esc_html__(
'Delete reference to original item.',
- 'duplicate-post'
+ 'duplicate-post',
),
\wp_kses(
\__(
'The original item this was copied from is: ',
- 'duplicate-post'
+ 'duplicate-post',
),
[
'span' => [
'class' => [],
],
- ]
- )
+ ],
+ ),
);
}
diff --git a/src/ui/link-builder.php b/src/ui/link-builder.php
index 4ea9aa799..3a8bc98d8 100644
--- a/src/ui/link-builder.php
+++ b/src/ui/link-builder.php
@@ -91,7 +91,7 @@ public function build_link( $post, $context, $action_name ) {
* @return string
*/
\apply_filters( 'duplicate_post_get_clone_post_link', \admin_url( 'admin.php' . $action ), $post->ID, $context, $action_name ),
- $action_name . '_' . $post->ID
+ $action_name . '_' . $post->ID,
);
}
}
diff --git a/src/ui/metabox.php b/src/ui/metabox.php
index e1646536e..9184003ba 100644
--- a/src/ui/metabox.php
+++ b/src/ui/metabox.php
@@ -33,7 +33,7 @@ public function __construct( Permissions_Helper $permissions_helper ) {
* @return void
*/
public function register_hooks() {
- if ( \intval( \get_option( 'duplicate_post_show_original_meta_box' ) ) === 1 ) {
+ if ( (int) \get_option( 'duplicate_post_show_original_meta_box' ) === 1 ) {
\add_action( 'add_meta_boxes', [ $this, 'add_custom_metabox' ], 10, 2 );
}
}
@@ -47,6 +47,11 @@ public function register_hooks() {
* @return void
*/
public function add_custom_metabox( $post_type, $post ) {
+ // Don't show the metabox in the block editor, we use the sidebar panel instead.
+ if ( \use_block_editor_for_post( $post ) ) {
+ return;
+ }
+
$enabled_post_types = $this->permissions_helper->get_enabled_post_types();
if ( \in_array( $post_type, $enabled_post_types, true )
@@ -61,7 +66,7 @@ public function add_custom_metabox( $post_type, $post ) {
$post_type,
'side',
'default',
- [ 'original' => $original_item ]
+ [ 'original' => $original_item ],
);
}
}
@@ -99,15 +104,15 @@ public function custom_metabox_html( $post, $metabox ) {
/* translators: %s: post title */
\__(
'The original item this was copied from is: %s ',
- 'duplicate-post'
+ 'duplicate-post',
),
[
'span' => [
'class' => [],
],
- ]
+ ],
),
- Utils::get_edit_or_view_link( $original_item ) // phpcs:ignore WordPress.Security.EscapeOutput
+ Utils::get_edit_or_view_link( $original_item ), // phpcs:ignore WordPress.Security.EscapeOutput
);
?>
diff --git a/src/ui/newsletter.php b/src/ui/newsletter.php
index 7b6f6dd2b..902a12093 100644
--- a/src/ui/newsletter.php
+++ b/src/ui/newsletter.php
@@ -16,14 +16,13 @@ public static function newsletter_signup_form() {
$newsletter_form_response = self::newsletter_handle_form();
-
$copy = \sprintf(
/* translators: 1: Yoast */
\esc_html__(
'If you want to stay up to date about all the exciting developments around Duplicate Post, subscribe to the %1$s newsletter!',
- 'duplicate-post'
+ 'duplicate-post',
),
- 'Yoast'
+ 'Yoast',
);
$email_label = \esc_html__( 'Email address', 'duplicate-post' );
@@ -32,10 +31,10 @@ public static function newsletter_signup_form() {
/* translators: %1$s and %2$s are replaced by opening and closing anchor tags. */
\esc_html__(
'Yoast respects your privacy. Read %1$sour privacy policy%2$s on how we handle your personal information.',
- 'duplicate-post'
+ 'duplicate-post',
),
'',
- ' '
+ '',
);
$response_html = '';
@@ -49,7 +48,7 @@ public static function newsletter_signup_form() {
$html = '
- '
+ ',
);
$this->instance->check_changes_action_handler();
@@ -153,6 +161,8 @@ public function test_check_changes_action_handler_successful() {
* Tests the check_changes_action_handler function when no ID has been passed in the query.
*
* @covers \Yoast\WP\Duplicate_Post\Handlers\Check_Changes_Handler::check_changes_action_handler
+ *
+ * @return void
*/
public function test_check_changes_action_handler_no_id_in_query() {
Monkey\Functions\expect( '\wp_die' )
@@ -165,6 +175,8 @@ public function test_check_changes_action_handler_no_id_in_query() {
* Tests the check_changes_action_handler function when there is no post.
*
* @covers \Yoast\WP\Duplicate_Post\Handlers\Check_Changes_Handler::check_changes_action_handler
+ *
+ * @return void
*/
public function test_check_changes_action_handler_no_post() {
$_GET['post'] = '123';
@@ -192,6 +204,8 @@ public function test_check_changes_action_handler_no_post() {
* @covers \Yoast\WP\Duplicate_Post\Handlers\Check_Changes_Handler::check_changes_action_handler
* @runInSeparateProcess
* @preserveGlobalState disabled
+ *
+ * @return void
*/
public function test_check_changes_action_handler_no_original() {
$utils = Mockery::mock( 'alias:\Yoast\WP\Duplicate_Post\Utils' );
diff --git a/tests/Unit/Handlers/Save_Post_Handler_Test.php b/tests/Unit/Handlers/Save_Post_Handler_Test.php
new file mode 100644
index 000000000..c07608778
--- /dev/null
+++ b/tests/Unit/Handlers/Save_Post_Handler_Test.php
@@ -0,0 +1,286 @@
+permissions_helper = Mockery::mock( Permissions_Helper::class );
+
+ $this->instance = new Save_Post_Handler( $this->permissions_helper );
+ }
+
+ /**
+ * Tears down the test.
+ *
+ * @return void
+ */
+ protected function tear_down() {
+ parent::tear_down();
+
+ unset( $_POST['duplicate_post_remove_original'] );
+ }
+
+ /**
+ * Tests the constructor.
+ *
+ * @covers ::__construct
+ *
+ * @return void
+ */
+ public function test_constructor() {
+ $this->assertInstanceOf(
+ Permissions_Helper::class,
+ $this->getPropertyValue( $this->instance, 'permissions_helper' ),
+ );
+ }
+
+ /**
+ * Tests the registration of the hooks when both options are disabled.
+ *
+ * @covers ::register_hooks
+ *
+ * @return void
+ */
+ public function test_register_hooks_no_options_enabled() {
+ Functions\expect( 'get_option' )
+ ->with( 'duplicate_post_show_original_meta_box' )
+ ->andReturn( '0' );
+
+ Functions\expect( 'get_option' )
+ ->with( 'duplicate_post_show_original_column' )
+ ->andReturn( '0' );
+
+ $this->instance->register_hooks();
+
+ $this->assertFalse( \has_action( 'save_post', [ $this->instance, 'delete_on_save_post' ] ) );
+ }
+
+ /**
+ * Tests the registration of the hooks when meta box option is enabled.
+ *
+ * @covers ::register_hooks
+ *
+ * @return void
+ */
+ public function test_register_hooks_with_meta_box_option_enabled() {
+ Functions\expect( 'get_option' )
+ ->with( 'duplicate_post_show_original_meta_box' )
+ ->andReturn( '1' );
+
+ $this->instance->register_hooks();
+
+ $this->assertNotFalse( \has_action( 'save_post', [ $this->instance, 'delete_on_save_post' ] ) );
+ }
+
+ /**
+ * Tests the registration of the hooks when column option is enabled.
+ *
+ * @covers ::register_hooks
+ *
+ * @return void
+ */
+ public function test_register_hooks_with_column_option_enabled() {
+ Functions\expect( 'get_option' )
+ ->once()
+ ->with( 'duplicate_post_show_original_meta_box' )
+ ->andReturn( '0' );
+
+ Functions\expect( 'get_option' )
+ ->once()
+ ->with( 'duplicate_post_show_original_column' )
+ ->andReturn( '1' );
+
+ $this->instance->register_hooks();
+
+ $this->assertNotFalse( \has_action( 'save_post', [ $this->instance, 'delete_on_save_post' ] ) );
+ }
+
+ /**
+ * Tests that delete_on_save_post returns early during autosave.
+ *
+ * @covers ::delete_on_save_post
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ *
+ * @return void
+ */
+ public function test_delete_on_save_post_returns_during_autosave() {
+ // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- This is a WordPress core constant.
+ \define( 'DOING_AUTOSAVE', true );
+
+ Functions\expect( 'current_user_can' )->never();
+ Functions\expect( 'get_post' )->never();
+ Functions\expect( 'delete_post_meta' )->never();
+
+ $this->instance->delete_on_save_post( 123 );
+ }
+
+ /**
+ * Tests that delete_on_save_post returns early when user cannot edit post.
+ *
+ * @covers ::delete_on_save_post
+ *
+ * @return void
+ */
+ public function test_delete_on_save_post_returns_when_user_cannot_edit() {
+ Functions\expect( 'current_user_can' )
+ ->with( 'edit_post', 123 )
+ ->andReturn( false );
+
+ Functions\expect( 'get_post' )->never();
+ Functions\expect( 'delete_post_meta' )->never();
+
+ $this->instance->delete_on_save_post( 123 );
+ }
+
+ /**
+ * Tests that delete_on_save_post returns early when post does not exist.
+ *
+ * @covers ::delete_on_save_post
+ *
+ * @return void
+ */
+ public function test_delete_on_save_post_returns_when_post_not_found() {
+ Functions\expect( 'current_user_can' )
+ ->with( 'edit_post', 123 )
+ ->andReturn( true );
+
+ Functions\expect( 'get_post' )
+ ->with( 123 )
+ ->andReturn( null );
+
+ $this->permissions_helper->shouldNotReceive( 'is_rewrite_and_republish_copy' );
+ Functions\expect( 'delete_post_meta' )->never();
+
+ $this->instance->delete_on_save_post( 123 );
+ }
+
+ /**
+ * Tests that delete_on_save_post returns early for Rewrite & Republish copy.
+ *
+ * @covers ::delete_on_save_post
+ *
+ * @return void
+ */
+ public function test_delete_on_save_post_returns_for_rewrite_republish_copy() {
+ $post = Mockery::mock( WP_Post::class );
+ $post->ID = 123;
+
+ Functions\expect( 'current_user_can' )
+ ->with( 'edit_post', 123 )
+ ->andReturn( true );
+
+ Functions\expect( 'get_post' )
+ ->with( 123 )
+ ->andReturn( $post );
+
+ $this->permissions_helper
+ ->shouldReceive( 'is_rewrite_and_republish_copy' )
+ ->with( $post )
+ ->andReturn( true );
+
+ Functions\expect( 'delete_post_meta' )->never();
+
+ $this->instance->delete_on_save_post( 123 );
+ }
+
+ /**
+ * Tests that delete_on_save_post does nothing when checkbox is not checked.
+ *
+ * @covers ::delete_on_save_post
+ *
+ * @return void
+ */
+ public function test_delete_on_save_post_does_nothing_without_checkbox() {
+ $post = Mockery::mock( WP_Post::class );
+ $post->ID = 123;
+
+ Functions\expect( 'current_user_can' )
+ ->with( 'edit_post', 123 )
+ ->andReturn( true );
+
+ Functions\expect( 'get_post' )
+ ->with( 123 )
+ ->andReturn( $post );
+
+ $this->permissions_helper
+ ->shouldReceive( 'is_rewrite_and_republish_copy' )
+ ->with( $post )
+ ->andReturn( false );
+
+ // Checkbox not set.
+ unset( $_POST['duplicate_post_remove_original'] );
+
+ Functions\expect( 'delete_post_meta' )->never();
+
+ $this->instance->delete_on_save_post( 123 );
+ }
+
+ /**
+ * Tests that delete_on_save_post removes the meta when checkbox is checked.
+ *
+ * @covers ::delete_on_save_post
+ *
+ * @return void
+ */
+ public function test_delete_on_save_post_removes_meta_when_checkbox_checked() {
+ $post = Mockery::mock( WP_Post::class );
+ $post->ID = 123;
+
+ Functions\expect( 'current_user_can' )
+ ->with( 'edit_post', 123 )
+ ->andReturn( true );
+
+ Functions\expect( 'get_post' )
+ ->with( 123 )
+ ->andReturn( $post );
+
+ $this->permissions_helper
+ ->shouldReceive( 'is_rewrite_and_republish_copy' )
+ ->with( $post )
+ ->andReturn( false );
+
+ // Checkbox is set.
+ $_POST['duplicate_post_remove_original'] = '1';
+
+ Functions\expect( 'delete_post_meta' )
+ ->with( 123, '_dp_original' )
+ ->once();
+
+ $this->instance->delete_on_save_post( 123 );
+ }
+}
diff --git a/tests/permissions-helper-test.php b/tests/Unit/Permissions_Helper_Test.php
similarity index 91%
rename from tests/permissions-helper-test.php
rename to tests/Unit/Permissions_Helper_Test.php
index 9778d8ded..fd8f9ad97 100644
--- a/tests/permissions-helper-test.php
+++ b/tests/Unit/Permissions_Helper_Test.php
@@ -1,6 +1,6 @@
assertSame(
$copy,
- $this->instance->has_scheduled_rewrite_and_republish_copy( $post )
+ $this->instance->has_scheduled_rewrite_and_republish_copy( $post ),
);
}
@@ -200,6 +220,8 @@ public function test_has_scheduled_rewrite_and_republish_copy_successful() {
* Tests has_scheduled_rewrite_and_republish_copy function when post has no R&R copy.
*
* @covers \Yoast\WP\Duplicate_Post\Permissions_Helper::has_scheduled_rewrite_and_republish_copy
+ *
+ * @return void
*/
public function test_has_scheduled_rewrite_and_republish_copy_no_copy() {
$post = Mockery::mock( WP_Post::class );
@@ -219,6 +241,8 @@ public function test_has_scheduled_rewrite_and_republish_copy_no_copy() {
* Tests has_scheduled_rewrite_and_republish_copy function when the copy is not scheduled.
*
* @covers \Yoast\WP\Duplicate_Post\Permissions_Helper::has_scheduled_rewrite_and_republish_copy
+ *
+ * @return void
*/
public function test_has_scheduled_rewrite_and_republish_copy_not_scheduled() {
$post = Mockery::mock( WP_Post::class );
@@ -244,8 +268,10 @@ public function test_has_scheduled_rewrite_and_republish_copy_not_scheduled() {
* @covers \Yoast\WP\Duplicate_Post\Permissions_Helper::is_edit_post_screen
* @dataProvider is_edit_post_screen_provider
*
- * @param mixed $original Input value.
- * @param mixed $expected Expected output.
+ * @param array