array( 'type' => 'array', 'default' => array( 'post', 'page' ), 'ui' => 'post_types', ), 'title_suffix' => array( 'type' => 'string', 'default' => '(copy)', ), 'open_editor' => array( 'type' => 'bool', 'default' => true, ), 'with_children' => array( 'type' => 'bool', 'default' => false, ), ); } /** * Field labels, resolved at render time where translation is safe. * * @param string $key Field key. * @return string */ public function field_label( $key ) { switch ( $key ) { case 'post_types': return __( 'Offer Duplicate on these post types', 'adminkeep' ); case 'title_suffix': return __( 'Added to the end of the copy\'s title', 'adminkeep' ); case 'open_editor': return __( 'Open the copy in the editor straight away', 'adminkeep' ); case 'with_children': return __( 'When copying a page, copy its child pages too', 'adminkeep' ); default: return ''; } } /** * What switching this on actually does. * * @return string[] */ public function how_it_works() { return array( __( 'Adds a Duplicate link under each post on the list screen, for the post types you choose.', 'adminkeep' ), __( 'The copy is created as a draft, so nothing appears on your site until you publish it.', 'adminkeep' ), __( 'Copies the content, excerpt, custom fields, categories, tags and featured image. Page builder layouts from Elementor, ACF and similar are copied exactly, not re-encoded.', 'adminkeep' ), __( 'The post you copied is never modified — not its title, not its address, not its date.', 'adminkeep' ), __( 'Nothing is added to your database until you use it. Switch this off and the link disappears; every copy you already made stays.', 'adminkeep' ), ); } /** * Register hooks. * * @param array $settings Resolved settings. * @return void */ public function boot( array $settings ) { $this->settings = $settings; add_action( 'admin_action_' . self::ACTION, array( $this, 'handle_request' ) ); } /** * Declare the row action. * * Note what is absent: this feature names no list-table hook. It says what it wants offered * and the surface decides how to draw it. * * @param AdminSurface $surface Surface to declare onto. * @return void */ public function register_surface( AdminSurface $surface ) { $post_types = isset( $this->settings['post_types'] ) ? (array) $this->settings['post_types'] : array(); if ( empty( $post_types ) ) { return; } $surface->register_row_action( new RowAction( array( 'id' => 'adminkeep-duplicate', 'label' => array( $this, 'label' ), 'post_types' => $post_types, 'capability' => 'edit_post', 'url' => array( $this, 'action_url' ), ) ) ); } /** * The nonced URL that performs the duplication. * * @param \WP_Post $post Post the link is rendered for. * @return string */ public function action_url( $post ) { return wp_nonce_url( admin_url( 'admin.php?action=' . self::ACTION . '&post=' . $post->ID ), self::ACTION . '_' . $post->ID ); } /** * Perform a duplication requested from the list screen. * * @return void */ public function handle_request() { $post_id = isset( $_REQUEST['post'] ) ? absint( $_REQUEST['post'] ) : 0; check_admin_referer( self::ACTION . '_' . $post_id ); $copy_id = $this->duplicator()->duplicate( $post_id, $this->duplicate_args() ); if ( is_wp_error( $copy_id ) ) { wp_die( esc_html( $copy_id->get_error_message() ), esc_html__( 'Could not duplicate', 'adminkeep' ), array( 'response' => 403 ) ); } wp_safe_redirect( $this->redirect_to( $copy_id, $post_id ) ); exit; } /** * Arguments for the duplicator, from settings. * * @return array */ private function duplicate_args() { $suffix = isset( $this->settings['title_suffix'] ) ? (string) $this->settings['title_suffix'] : ''; return array( // Stored without the leading space so the settings field is not misleadingly blank // at the start; the space belongs to the joining, not to the value. 'title_suffix' => '' === $suffix ? '' : ' ' . $suffix, 'with_children' => ! empty( $this->settings['with_children'] ), ); } /** * Where to send the user afterwards. * * @param int $copy_id The new post. * @param int $original_id The post that was copied. * @return string */ private function redirect_to( $copy_id, $original_id ) { if ( ! empty( $this->settings['open_editor'] ) ) { return get_edit_post_link( $copy_id, 'raw' ); } $post_type = get_post_type( $original_id ); return admin_url( 'edit.php?post_type=' . rawurlencode( $post_type ) ); } /** * Offer duplication to the Abilities API. * * @return array> */ public function abilities() { return array( 'duplicate-post' => array( 'label' => __( 'Duplicate a post', 'adminkeep' ), 'description' => __( 'Copies a post, page or custom post type. The copy is created as a draft with the same content, custom fields and taxonomy terms; the post being copied is not modified.', 'adminkeep' ), 'input_schema' => array( 'type' => 'object', 'properties' => array( 'post_id' => array( 'type' => 'integer', 'description' => __( 'The post to copy.', 'adminkeep' ), ), ), 'required' => array( 'post_id' ), ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'post_id' => array( 'type' => 'integer', 'description' => __( 'The new draft.', 'adminkeep' ), ), ), ), 'permission_callback' => array( $this, 'ability_can_duplicate' ), 'execute_callback' => array( $this, 'ability_duplicate' ), 'meta' => array( 'annotations' => array( 'readonly' => false, 'destructive' => false, 'idempotent' => false, ), ), ), ); } /** * Whether the current user may duplicate the requested post. * * Two gates beyond the service's own capability check: the post must exist, and its type must * be one the site owner ticked. An agent gets the same reach as the row action, not more — * duplication of a post type the owner did not opt into is not on offer anywhere else, so it * must not be on offer here. * * @param array $input Ability input. * @return bool */ public function ability_can_duplicate( $input = array() ) { $post = get_post( isset( $input['post_id'] ) ? (int) $input['post_id'] : 0 ); if ( ! $post instanceof \WP_Post ) { return false; } $post_types = isset( $this->settings['post_types'] ) ? (array) $this->settings['post_types'] : array(); if ( ! in_array( $post->post_type, $post_types, true ) ) { return false; } return $this->duplicator()->user_can_duplicate( $post ); } /** * Duplicate the requested post. * * Same service, same arguments and therefore the same settings as the row action: an agent * that copies a page gets the title suffix and the child-page behaviour the owner configured. * * @param array $input Ability input. * @return array|\WP_Error */ public function ability_duplicate( $input = array() ) { $copy_id = $this->duplicator()->duplicate( isset( $input['post_id'] ) ? (int) $input['post_id'] : 0, $this->duplicate_args() ); if ( is_wp_error( $copy_id ) ) { return $copy_id; } return array( 'post_id' => (int) $copy_id ); } /** * The duplicator service. * * @return PostDuplicator */ private function duplicator() { return new PostDuplicator(); } }