Blurbette Plugin: Copy Metabox


In this chapter, I’ll create the metabox enabling a user to copy any post to a new Blurbette. This action shouldn’t force the user to leave the edit panel, so copying ought to take place via AJAX. And, for good measure, why not track whether a post has already been copied to a Blurbette, and link to it?

My plan is to use post metadata. When a post is copied to a Blurbette, then I’ll add a meta value for the post, containing the Blurbette’s ID; plus a meta value for the Blurbette, containing the post’s ID.

The metadata keys will be constants, and for reasons that’ll be clear later in this chapter, those constants will be defined in WPCX_Blurbette_Def. I’ll add them now:

class WPCX_Blurbette_Def {
    const POST_TYPE = 'wpcx_blurbette';
    const TEXT_DOMAIN = 'wpcx_blurbette';
    const OPTION_METAKEY = 'wpcx_blurbette_options';
    const COPIED_TO_METAKEY = 'wpcx_blurbette_copied_to';
    const COPIED_FROM_METAKEY = 'wpcx_blurbette_copied_from';

Using the abstract class from the last chapter, the basic metabox structure will be a cinch. The bulk of this code will focus on the Javascript and AJAX required to handle the copying action.

Here’s everything without the Javascript / AJAX:

<?php
	class WPCX_Blurbette_Copy_Metabox extends WPCX_Abs_Metabox {
		function output_meta_box( $post ) {
			$copied_ids = ( array ) get_post_meta( $post->ID, WPCX_Blurbette_Def::COPIED_TO_METAKEY, false );
			?><div id="wpcx_copied_blurbettes"<?php if ( ! count( $copied_ids ) ) echo ' style="display:none"'; ?>>
				<p><?php _e( 'This post has been copied to these blurbettes:', WPCX_Blurbette_Def::TEXT_DOMAIN) ?></p>
				<ul class="blurbette_list">
				<?php foreach( $copied_ids as $id ) :
					echo $this->blurbette_editor_html( $id );
				endforeach; ?>
				</ul>
			</div>
			<a href="javascript:void(0)" onclick="wpcxCopyNewBlurbetteAjax()" class="button">Copy to a new Blurbette</a>
			<?php
		}
		
		function blurbette_editor_html( $id ) {
			$editor_url = add_query_arg( 
				array( 
					'post' => $id, 
					'action' => 'edit'
				 ), 
				admin_url( 'post.php' )
			 );
			$title = get_the_title( $id );
			return sprintf( '<li><a href="%s" title="%s">%s</a></li>', 
				$editor_url, 
				esc_attr( $title ), 
				esc_html( $title )
			 ) . PHP_EOL;
		}
	} // end class WPCX_Blurbette_Copy_Metabox

Simple, right? In output_meta_box() I store the post meta into an array variable $copied_ids, then define a <DIV> element that will list them. If none, I add a "display:none" style attribute to it.

Within that element, I loop through the $copied_ids and call a separate method blurbette_editor_html() that formats the HTML with hyperlink for each.

Lastly, I output the Copy button. Side note: I almost always use href="javascript:void(0)" for null anchors. Lots of people use href="#" but that adds a hash mark to the browser location bar, and often causes the page to scroll to the top.

Now I’ll prepare the class for AJAX calls, by inserting lines at the top:

<?php
	class WPCX_Blurbette_Copy_Metabox extends WPCX_Abs_Metabox {
		
		const AJAX_ACTION_SAVE = 'wpcx_ajax_save_copy';
		
		protected function do_all_hooks() {
			parent::do_all_hooks();
			// ajax...
			add_action( 'wp_ajax_' . self::AJAX_ACTION_SAVE, array( $this, 'ajax_save_copy' ) );
			add_action( 'wp_ajax_nopriv_' . self::AJAX_ACTION_SAVE, array( $this, 'ajax_save_copy' ) );
		}

I define a constant that contains the unique action name for AJAX. Then, since I want to add more code to the do_all_hooks() method, I extend it by defining it here and calling parent::do_all_hooks(); (just like I did in a previous chapter) followed by the new AJAX hooks.

I’ll want to use jQuery so I’ll define this immediately following:

		function q_admin_scripts() {
			wp_enqueue_script( 'jquery' );
		}

Recall from the last chapter, this is a hooked method defined by the abstract class, but defined as empty — so nothing would happen if I were to call parent::q_admin_scripts().

Now to jump down and write the ajax_save_copy() method, called by AJAX. Taking it in chunks:

		public function ajax_save_copy() {
			$return = array( 'payload'=>array(), 'status'=>'error', 'errorString'=>'Unauthorized.' );
			if ( ! ( $_POST['post_id'] = intval( $_POST['post_id'] ) ) ) die( json_encode( $return ) );
			if ( ! wp_verify_nonce( $_POST[$this->noncename], $this->nonceval ) ) die( json_encode( $return ) );
			if ( ! current_user_can( $this->capability ) ) die( json_encode( $return ) );

First I define my output: an array containing status (I’ll check against ‘ok’), errorString, and payload — the returned data.

Then, I bail if certain conditions are not met: if the passed ID is not an integer, if the nonce check fails, and if the current user lacks capability. Recall that the abstract class defines certain properties like $this->noncename, $this->nonceval, and $this->capability (‘edit_post’) by default.

Next:

			$compos = wp_parse_args( 
				$_POST, 
				array( 
					'title' => __( 'Blurbette Copied From ' . $_POST['post_id'], WPCX_Blurbette_Def::TEXT_DOMAIN ), 
					'content' => ''
				 )
			 );
			$blurbette_post = array( 
				'post_title' => $compos['title'], 
				'post_content' => $compos['content'], 
				'post_type' => WPCX_Blurbette_Def::POST_TYPE, 
				'post_status' => 'publish'
			 );
			$blurbette_result = wp_insert_post( $blurbette_post, true );
			if ( is_wp_error( $blurbette_result ) ) :
				$return['errorString'] = array_pop( $blurbette_result->get_error_message() );
				die( json_encode( $return ) );
			endif;

Prepping to insert the new Blurbette, I use wp_parse_args to define defaults if data is missing, then build an array containing necessary elements for the new post. Then, I use wp_insert_post() to add the new Blurbette to the database.

You might notice the new Blurbette’s content is passed through a $_POST variable. Why didn’t I fetch the content from the post in the database? Because I want the copied Blurbette to reflect the exact content of the edit window at the time the button was pressed, even if the user hasn’t yet updated the post. So I’ll use Javascript to grab that content, and pass it here.

Note: It is necessary to sanitize incoming data; fortunately wp_insert_post() does it for us automatically.

If for some reason the insert fails, I set the $return['errorString'] to the returned error message so it can be presented to the user, then die.

$blurbette_result should now be an integer, the ID of the newly_inserted Blurbette. So I can finish by adding post meta to the database:

			$return['errorString'] = __( 'Sorry, there was a problem updating your blurbette data.', WPCX_Blurbette_Def::TEXT_DOMAIN );
			if ( ! add_post_meta( $_POST['post_id'], WPCX_Blurbette_Def::COPIED_TO_METAKEY, $blurbette_result ) )   die( json_encode( $return ) );
			if ( ! add_post_meta( $blurbette_result, WPCX_Blurbette_Def::COPIED_FROM_METAKEY, $_POST['post_id'] ) ) die( json_encode( $return ) );
			
			$return['status'] = 'ok';
			$return['payload'] = $blurbette_post;
			$return['payload']['ID'] = $blurbette_result;
			$return['payload']['editor_html'] = $this->blurbette_editor_html( $blurbette_result );
			
			echo json_encode( $return );
			die();
		}

I add a meta datum (COPIED_TO_METAKEY) for the post, containing the new Blurbette ID $blurbette_result, and a meta datum (COPIED_FROM_METAKEY) to the Blurbette containing the post ID. If for some reason either of those fail, the method dies with an error message.

After that, I fill the ‘payload’, with the Blurbette content and ID. Plus, neatly, I can use the same blurbette_editor_html() method to generate a new line of HTML for the metabox output.

The Javascript

The last piece of the puzzle is the Javascript that processes the AJAX call. I’ll slip these two functions inside the output_meta_box() method, right after the last HTML button — I suppose I should maintain this in a separate file, but I’m lazy:

			<a href="javascript:void( 0 )" onclick="wpcxCopyNewBlurbetteAjax()" class="button">Copy to a new Blurbette</a>
			<script type="text/javascript">
				function wpcxCopyNewBlurbetteAjax() {
				   var newtitle = prompt( "<?php _e( "What would you like the Blurbette's title to be?", WPCX_Blurbette_Def::TEXT_DOMAIN ) ?>", jQuery( 'input#title' ).val() );
				   if ( !newtitle || !newtitle.length ) { return; }
				   jQuery.post( 
				        wpcxAjaxVars.url, 
				        {	"action"		: "<?php echo self::AJAX_ACTION_SAVE ?>", 
				        	"post_id"		: <?php echo $post->ID ?>, 
				        	"title"			: newtitle, 
				        	"content"		: wpcxGetEditorContent(), 
				        	"<?php echo $this->noncename ?>" : "<?php echo wp_create_nonce( $this->nonceval ) ?>"
				        }, 
				        
				        function( jjson ){
				            if ( 'ok' == jjson.status ) {
					            jQuery( '#wpcx_copied_blurbettes ul.blurbette_list' ).append( jjson.payload.editor_html );
					            jQuery( '#wpcx_copied_blurbettes' ).show();
					        } else {
						        alert( jjson.errorString );
					        }
				        }, 
				        'json'
				 );
				}
				function wpcxGetEditorContent() {
					var isRich = ( typeof tinyMCE != "undefined" ) && tinyMCE.activeEditor && !tinyMCE.activeEditor.isHidden();
					if ( isRich ) {
						var ed = tinyMCE.get( 'content' );
						if ( ed ) { return ed.getContent(); } else { return false; }
					} else {
						return jQuery( '#wp-content-editor-container .wp-editor-area' ).val();
					}
				}
			</script>
			
			<?php
		}

In the wpcxCopyNewBlurbetteAjax(), I prompt for the user’s preferred title, pre-populating it with the post’s current title (jQuery( 'input#title' ).val(), referring to the #title input element at the top of the page). If it comes back empty or null (user cancelled), bail.

Then, I use jQuery’s post() function to post a data object, using the second function to grab the current content of the editor window. Notice I can use WordPress’ wp_create_nonce() on the $this->nonceval defined by the abstract class.

In the return function, I can simply add the returned .editor_html property to the metabox’s <DIV> element, and show() it. Or alert the .errorString if there’s a problem.

In wpcxGetEditorContent() I first determine whether the edit window is in ‘rich text’ mode, by running a couple of checks. If so, I use TinyMCE’s builtin .getContent() function to retrieve it; otherwise the edit window is simply a <textarea> so its value is returned.

Tidying Up

I’ve defined post meta for posts and Blurbettes. When WordPress deletes a post of any type (including a Blurbette), it also deletes all the metadata belonging to it. But in each of these metadata, the value is an ID, and the datum belongs to a different post. In other words, if I were to delete a Blurbette, its COPIED_FROM metadata would get deleted automatically because that belongs to it — but not the COPIED_TO metadata that belongs to another post.

The responsible thing is to delete these additional metadata, so there aren’t dead rows cluttering up the database table, and possible errors aren’t introduced.

WordPress provides a before_delete_post action and passes the ID of the post it’s about to delete. Returning to WPCX_Blurbette_Def, I can add a hooked method that cleans up the metadata. This is why I added the metadata keys as constants within this class.

	public static function delete_copied_meta( $postid ) {
		if ( empty( $postid ) || ! is_numeric( $postid ) ) return;
		global $wpdb;
		switch( get_post_type( $postid ) ) :
			case 'post': 
				$meta_key = self::COPIED_FROM_METAKEY;
			break;
			case self::POST_TYPE :
				$meta_key = self::COPIED_TO_METAKEY;
			break;
			default:
				return;
			break;
		endswitch;
		$wpdb->delete(
			$wpdb->postmeta,
			array(
				'meta_key'		=> $meta_key,
				'meta_value'	=> $postid
			),
			array( '%s', '%d' )
		);
		
	}

I use a switch statement because I only want to act on posts and Blurbettes (return otherwise). If a post, then I want to delete all the COPIED_FROM’s that point to it; if a Blurbette then I want to delete the COPIED_TO’s that point to it. Then I use the $wpdb->delete() method to delete them.

Finally, of course add this hook to WPCX_Blurbette_Def’s do_all_hooks() method:

public static function do_all_hooks() {
    add_action( 'init', array( __CLASS__, 'register_cpt' ) );
    add_action( 'admin_enqueue_scripts', array( __CLASS__, 'define_js_ajax_vars' ) );
    add_action( 'before_delete_post', array( __CLASS__, 'delete_copied_meta' ), 15, 1 );
}

Instantiating

Now that all the code has been written, this class must be instantiated in WPCX_Blurbette_Registry:

function instantiate_the_rest() {
    if ( is_admin() ) :
        if ( ! empty( $this->options['use_shortcode'] ) ) :
            $this->register( 'WPCX_Blurbette_MCE_WithDialog', array(
                'registry'  => $this,
                'name'      => 'WPCXBlurbette',
                'row'       => 2,
                'js'        => plugin_dir_url( __FILE__ ) . 'mce/mce_blurbette.js'
            ) );
        endif;
        $this->register( 'WPCX_Blurbette_AdminPanel', array(
            'registry'=>$this
        ) );
        if ( ! empty( $this->options['use_copy_metabox'] ) ) :
            $this->register( 'WPCX_Blurbette_Copy_Metabox', array(
                'registry'		=> $this,
                'title'			=> 'Copy to Blurbette',
                'post_types'	=> array( 'post', 'page' )
            ) );
        endif;
    endif;
}

I check the option value set on the admin control panel. If enabled, I register the new class object, giving it a metabox title, and placing the new metabox on posts and pages by passing it an array with those post types.

Recapping the code

Here’s the entire WPCX_Blurbette_Copy_Metabox class:

<?php
	class WPCX_Blurbette_Copy_Metabox extends WPCX_Abs_Metabox {
		
		const AJAX_ACTION_SAVE = 'wpcx_ajax_save_copy';
		
		protected function do_all_hooks() {
			parent::do_all_hooks();
			// ajax...
			add_action( 'wp_ajax_' . self::AJAX_ACTION_SAVE, array( $this, 'ajax_save_copy' ) );
			add_action( 'wp_ajax_nopriv_' . self::AJAX_ACTION_SAVE, array( $this, 'ajax_save_copy' ) );
		}
		
		function q_admin_scripts() {
			wp_enqueue_script( 'jquery' );
		}
		
		function output_meta_box( $post ) {
			$copied_ids = ( array ) get_post_meta( $post->ID, WPCX_Blurbette_Def::COPIED_TO_METAKEY, false );
			?><div id="wpcx_copied_blurbettes"<?php if ( ! count( $copied_ids ) ) echo ' style="display:none"'; ?>>
				<p><?php _e( 'This post has been copied to these blurbettes:', WPCX_Blurbette_Def::TEXT_DOMAIN) ?></p>
				<ul class="blurbette_list">
				<?php foreach( $copied_ids as $id ) :
					echo $this->blurbette_editor_html( $id );
				endforeach; ?>
				</ul>
			</div>
			<a href="javascript:void( 0 )" onclick="wpcxCopyNewBlurbetteAjax()" class="button">Copy to a new Blurbette</a>
			<script type="text/javascript">
				function wpcxCopyNewBlurbetteAjax() {
				   var newtitle = prompt( "<?php _e( "What would you like the Blurbette's title to be?", WPCX_Blurbette_Def::TEXT_DOMAIN ) ?>", jQuery( 'input#title' ).val() );
				   if ( !newtitle || !newtitle.length ) { return; }
				   jQuery.post( 
				        wpcxAjaxVars.url, 
				        {	"action"		: "<?php echo self::AJAX_ACTION_SAVE ?>", 
				        	"post_id"		: <?php echo $post->ID ?>, 
				        	"title"			: newtitle, 
				        	"content"		: wpcxGetEditorContent(), 
				        	"<?php echo $this->noncename ?>" : "<?php echo wp_create_nonce( $this->nonceval ) ?>"
				        }, 
				        
				        function( jjson ){
				            if ( 'ok' == jjson.status ) {
					            jQuery( '#wpcx_copied_blurbettes ul.blurbette_list' ).append( jjson.payload.editor_html );
					            jQuery( '#wpcx_copied_blurbettes' ).show();
					        } else {
						        alert( jjson.errorString );
					        }
				        }, 
				        'json'
				 );
				}
				function wpcxGetEditorContent() {
					var isRich = ( typeof tinyMCE != "undefined" ) && tinyMCE.activeEditor && !tinyMCE.activeEditor.isHidden();
					if ( isRich ) {
						var ed = tinyMCE.get( 'content' );
						if ( ed ) { return ed.getContent(); } else { return false; }
					} else {
						return jQuery( '#wp-content-editor-container .wp-editor-area' ).val();
					}
				}
			</script>
			
			<?php
		}
		
		function blurbette_editor_html( $id ) {
			$editor_url = add_query_arg( 
				array( 
					'post' => $id, 
					'action' => 'edit'
				 ), 
				admin_url( 'post.php' )
			 );
			$title = get_the_title( $id );
			return sprintf( '<li><a href="%s" title="%s">%s</a></li>', 
				$editor_url, 
				esc_attr( $title ), 
				esc_html( $title )
			 ) . PHP_EOL;
		}
		
		public function ajax_save_copy() {
			$return = array( 'payload'=>array(), 'status'=>'error', 'errorString'=>'Unauthorized.' );
			if ( ! ( $_POST['post_id'] = intval( $_POST['post_id'] ) ) ) die( json_encode( $return ) );
			if ( ! wp_verify_nonce( $_POST[$this->noncename], $this->nonceval ) ) die( json_encode( $return ) );
			if ( ! current_user_can( $this->capability ) ) die( json_encode( $return ) );
			$compos = wp_parse_args( 
				$_POST, 
				array( 
					'title' => __( 'Blurbette Copied From ' . $_POST['post_id'], WPCX_Blurbette_Def::TEXT_DOMAIN ), 
					'content' => ''
				 )
			 );
			$blurbette_post = array( 
				'post_title' => $compos['title'], 
				'post_content' => $compos['content'], 
				'post_type' => WPCX_Blurbette_Def::POST_TYPE, 
				'post_status' => 'publish'
			 );
			$blurbette_result = wp_insert_post( $blurbette_post, true );
			if ( is_wp_error( $blurbette_result ) ) :
				$return['errorString'] = array_pop( $blurbette_result->get_error_message() );
				die( json_encode( $return ) );
			endif;
			
			$return['errorString'] = __( 'Sorry, there was a problem updating your blurbette data.', WPCX_Blurbette_Def::TEXT_DOMAIN );
			if ( ! add_post_meta( $_POST['post_id'], WPCX_Blurbette_Def::COPIED_TO_METAKEY, $blurbette_result ) )   die( json_encode( $return ) );
			if ( ! add_post_meta( $blurbette_result, WPCX_Blurbette_Def::COPIED_FROM_METAKEY, $_POST['post_id'] ) ) die( json_encode( $return ) );
			
			$return['status'] = 'ok';
			$return['payload'] = $blurbette_post;
			$return['payload']['ID'] = $blurbette_result;
			$return['payload']['editor_html'] = $this->blurbette_editor_html( $blurbette_result );
			
			add_post_meta( $blurbette_result, WPCX_Blurbette_Def::ALLOWED_POSTTYPE_METAKEY, WPCX_Blurbette_Def::POST_TYPE );

			if ( !empty( $this->registry->options['copied_everywhere'] ) && $this->registry->options['copied_everywhere'] == 'y' ) :
				add_post_meta( $blurbette_result, WPCX_Blurbette_Def::ALLOWED_WIDGET_METAKEY, 1 );
				$all_post_types = get_post_types( array( 
					'public' => true, 
					//'publicly_queryable' => true
				 ), 'objects' );
				if ( is_array( $all_post_types ) ) :
					foreach( $all_post_types as $post_type => $dummy ) :
						add_post_meta( $blurbette_result, WPCX_Blurbette_Def::ALLOWED_POSTTYPE_METAKEY, $post_type );
					endforeach;
				endif;
			endif;

			echo json_encode( $return );
			die();
		}
		
	} // end class WPCX_Blurbette_Copy_Metabox