diff --git a/composer.json b/composer.json index bdd713a..0ea3c49 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,9 @@ ], "minimum-stability": "stable", "autoload": { + "psr-4": { + "Press_Sync\\api\\": "includes/api" + }, "classmap": [ "includes/" ] diff --git a/includes/api/route/AbstractRoute.php b/includes/api/route/AbstractRoute.php new file mode 100644 index 0000000..1d2d6cf --- /dev/null +++ b/includes/api/route/AbstractRoute.php @@ -0,0 +1,129 @@ +namspace}/{$this->rest_base}/{$endpoint}. If you don't + * specify an endpoint, the route will point to the REST base. + * + * Example: + * ``` + * $this->namespace = 'filemanager/v1'; + * $this->rest_base = 'files'; + * $this->routes[] = [ 'callback'=> [ $this, 'get_info' ] ]; + * $this->routes['list'] = [ 'callback' => [ $this, 'list_items'] ]; + * ``` + * This registers two endpoints: + * - /filemanager/v1/files/ - This uses the get_info callback. + * - /filemanager/v1/files/list - This uses the list_items callback. + * + * Any configuration items that can be passed to the third parameter of `register_rest_route` + * may be used in the configuration array for each endpoint. + * + * @since NEXT + * @var array + */ + protected $routes = array(); + + /** + * Concrete classes should implement methods that hook into WordPress's rest_api_init event, at minimum. + * + * @since NEXT + * @return void + */ + abstract public function register_hooks(); + + /** + * Validate the supplied press_sync_key by the sending site. + * Target site can't receive data without a valid press_sync_key. + * + * @since 0.1.0 + * @return bool + */ + public function validate_sync_key() { + // @TODO Check for valid nonce. + $press_sync_key_from_remote = ''; + + if ( isset( $_REQUEST['press_sync_key'] ) ) { + $press_sync_key_from_remote = filter_var( $_REQUEST['press_sync_key'], FILTER_SANITIZE_STRING ); + } + + $press_sync_key = get_option( 'ps_key' ); + + return $press_sync_key && ( $press_sync_key === $press_sync_key_from_remote ); + } + + /** + * Registers routes to the WP API. + * + * @since NEXT + */ + public function register_routes() { + $defaults = array( + 'methods' => array( 'GET' ), + 'callback' => '__return_false', + 'permission_callback' => array( $this, 'validate_sync_key' ), + 'args' => array( + 'press_sync_key' => array( + 'required' => true, + ), + ), + ); + + + foreach ( $this->routes as $endpoint => $route_config ) { + $config = wp_parse_args( $route_config, $defaults ); + + if ( is_numeric( $endpoint ) ) { + $endpoint = ''; + } + + register_rest_route( + $this->namespace, + "{$this->rest_base}/{$endpoint}", + $config + ); + } + } + + /** + * Get remote data from an API request. + * + * @since NEXT + * + * @param string $request The requested datapoint. + * + * @return array + */ + public function get_data( $request ) { + $request = ltrim( $request, '/' ); + $url = \Press_Sync\API::get_remote_url( '', "{$this->rest_base}/{$request}", [ + 'request' => $request, + ] ); + + $response = \Press_Sync\API::get_remote_response( $url ); + + if ( empty( $response['body']['success'] ) ) { + return []; + } + + return $response['body']['data']; + } +} diff --git a/includes/api/route/Status.php b/includes/api/route/Status.php new file mode 100644 index 0000000..77ec750 --- /dev/null +++ b/includes/api/route/Status.php @@ -0,0 +1,9 @@ +rest_base = 'validation'; + } + + /** + * Registers API endpoints for the extending class. + * + * This registers an API endpoint at /namespace/route/endpoint/ based on the extending + * class's properties. The class should implement a static public method called + * get_api_response that can parse the request parameters and return the desired data. + * + * @since NEXT + */ + public function register_routes() { + foreach ( $this->routes as $route ) { + /* @var AbstractRoute $class */ + $class = new $route(); + $class->register_hooks(); + } + } +} diff --git a/includes/api/route/validation/Post.php b/includes/api/route/validation/Post.php new file mode 100644 index 0000000..ff75fbf --- /dev/null +++ b/includes/api/route/validation/Post.php @@ -0,0 +1,79 @@ +rest_base = 'validation/post'; + $this->data_source = new \Press_Sync\validation\Post(); + $this->routes = array( + 'count' => array( + 'callback' => array( $this->data_source, 'get_count' ), + ), + 'sample' => array( + 'callback' => array( $this, 'get_sample' ), + 'args' => array( + 'type' => array( 'required' => true ), + 'count' => array( 'required' => false ), + 'ids' => array( 'required' => false ), + 'press_sync_key' => array( 'required' => true ), + ), + ), + ); + } + + /** + * Register hooks for Post validation. + * + * @since NEXT + */ + public function register_hooks() { + add_action( 'rest_api_init', array( $this, 'register_routes' ) ); + } + + /** + * Get a sample of post data. + * + * @param \WP_REST_Request $request + * + * @return array + */ + public function get_sample( \WP_REST_Request $request ) { + $params = array( + 'count' => $request->get_param( 'count' ), + 'ids' => $request->get_param( 'ids' ), + 'type' => $request->get_param( 'type' ), + ); + + if ( $params['count'] ) { + $callback = "get_sample_{$params['type']}_data"; + return $this->data_source->{$callback}( $params['count'] ); + } + + if ( $params['ids'] ) { + $callback = "get_comparison_{$params['type']}"; + return $this->data_source->{$callback}( $params['ids'] ); + } + + return []; + } +} diff --git a/includes/api/route/validation/Taxonomy.php b/includes/api/route/validation/Taxonomy.php new file mode 100644 index 0000000..2cf0507 --- /dev/null +++ b/includes/api/route/validation/Taxonomy.php @@ -0,0 +1,70 @@ +rest_base = 'validation/taxonomy'; + $this->data_source = new \Press_Sync\validation\Taxonomy(); + $this->routes = array( + 'count' => array( + 'callback' => array( $this->data_source, 'get_count' ), + ), + 'post_terms' => array( + 'callback' => array( $this->data_source, 'get_post_count_by_taxonomy_term' ), + ), + 'sample' => array( + 'callback' => array( $this, 'get_sample' ), + 'args' => array( + 'type' => array( 'required' => true ), + ), + ), + ); + } + + /** + * Register hooks for Taxonomy validation. + * + * @since NEXT + */ + public function register_hooks() { + add_action( 'rest_api_init', array( $this, 'register_routes' ) ); + } + + /** + * Get sample taxonomy data. + * + * @param \WP_REST_Request $request The API request. + * + * @return array|\WP_Error + */ + public function get_sample( \WP_REST_Request $request ) { + $type = filter_var( $request->get_param( 'type' ), FILTER_SANITIZE_STRING ); + + if ( 'meta' === $type ) { + return $this->data_source->get_sample_meta(); + } + + return new \WP_Error( 'taxonomy_type_not_found', 'Invalid type parameter.', array( 'status' => 404 ) ); + } +} diff --git a/includes/api/route/validation/User.php b/includes/api/route/validation/User.php new file mode 100644 index 0000000..8f22ad3 --- /dev/null +++ b/includes/api/route/validation/User.php @@ -0,0 +1,37 @@ +rest_base = 'validation/user'; + $this->data_source = new \Press_Sync\validation\User(); + $this->routes['count'] = array( + 'callback' => array( $this->data_source, 'get_count' ), + ); + + $this->routes['samples'] = array( + 'callback' => array( $this->data_source, 'get_samples' ), + ); + } + + /** + * Register endpoint with WordPress. + */ + public function register_hooks() { + add_action( 'rest_api_init', array( $this, 'register_routes' ) ); + } +} diff --git a/includes/class-api.php b/includes/class-api.php index 0480470..a95297d 100644 --- a/includes/class-api.php +++ b/includes/class-api.php @@ -2,10 +2,14 @@ namespace Press_Sync; +use Press_Sync\api\route\Validation; + /** * The Press_Sync_API class. */ -class API extends \WP_REST_Controller { +class API { + + const NAMESPACE = 'press-sync/v1'; /** * Parent plugin class. @@ -49,6 +53,9 @@ public function __construct( $plugin ) { * @since 0.1.0 */ public function hooks() { + $validation = new Validation(); + $validation->register_routes(); + add_action( 'rest_api_init', array( $this, 'register_api_endpoints' ) ); add_action( 'press_sync_sync_post', array( $this, 'add_p2p_connections' ), 10, 2 ); } @@ -60,37 +67,28 @@ public function hooks() { */ public function register_api_endpoints() { - register_rest_route( 'press-sync/v1', '/status', array( - 'methods' => 'GET', + register_rest_route( self::NAMESPACE, '/status', array( + 'methods' => 'GET', 'callback' => array( $this, 'get_connection_status_via_api' ), ) ); - register_rest_route( 'press-sync/v1', '/status/(?P\d+)', array( - 'methods' => 'GET', + register_rest_route( self::NAMESPACE, '/status/(?P\d+)', array( + 'methods' => 'GET', 'callback' => array( $this, 'get_post_sync_status_via_api' ), ) ); - register_rest_route( 'press-sync/v1', '/sync', array( - 'methods' => array( 'GET', 'POST' ), - 'callback' => array( $this, 'sync_objects' ), + register_rest_route( self::NAMESPACE, '/sync', array( + 'methods' => array( 'GET', 'POST' ), + 'callback' => array( $this, 'sync_objects' ), 'permission_callback' => array( $this, 'validate_sync_key' ), ) ); - register_rest_route( 'press-sync/v1', '/progress/', array( + register_rest_route( self::NAMESPACE, '/progress/', array( 'methods' => array( 'GET' ), 'callback' => array( $this, 'get_sync_progress' ), 'permission_callback' => array( $this, 'validate_sync_key' ), 'args' => array( 'post_type', 'press_sync_key', 'preserve_ids' ), ) ); - - /* - @todo Complete the individual post syncing. - register_rest_route( 'press-sync/v1', '/sync/(?P\d+)', array( - 'methods' => array( 'GET', 'POST' ), - 'callback' => array( $this, 'sync_objects' ), - ) ); - */ - } /** @@ -1241,6 +1239,107 @@ private function maybe_update_term_meta( $term_id, $term_meta ) { } } + /** + * Gets the remote site URL and appends query parameters. + * + * @since 0.5.1 + * + * @param string $url A URL other than the stored remote URL to use. + * @param string $endpoint The remote site endpoint. + * + * @since 0.7.0 + * @param array $args (Optional) Array of query parameters. + * + * @return string + */ + public static function get_remote_url( $url, $endpoint = 'status', $args = array() ) { + if ( ! $url ) { + $url = get_option( 'ps_remote_domain' ); + } + + $url = trailingslashit( $url ); + $endpoint = ltrim( $endpoint, '/' ); + $query_args = wp_parse_args( $args, array( + 'press_sync_key' => get_option( 'ps_remote_key' ), + ) ); + + $remote_args = get_option( 'ps_remote_query_args' ); + + if ( ! empty( $remote_args ) ) { + $remote_args = ltrim( $remote_args, '?' ); + parse_str( $remote_args, $remote_args_array ); + $query_args = array_merge( $query_args, $remote_args_array ); + } + + $namespace = self::NAMESPACE; + return "{$url}wp-json/{$namespace}/{$endpoint}?" . http_build_query( $query_args ); + } + + /** + * Gets a remote server's response. + * + * Returns a response array on success-ish, or null on bad request method. + * + * @since NEXT + * + * @param string $url The remote server URL. + * @param array $args (Optional) The arguments to pass to wp_remote_* methods. + * @param string $method (Optional) Set the request method, defaults to 'GET'. + * @return mixed + */ + public static function get_remote_response( $url, $args = array(), $method = 'GET' ) { + $args = wp_parse_args( $args, array( + 'timeout' => 30, + ) ); + + $data = array(); + $data['body'] = ''; + + switch ( strtoupper( $method ) ) { + case 'GET': + $data['raw_response'] = wp_remote_get( $url, $args ); + break; + + case 'POST': + $data['raw_response'] = wp_remote_post( $url, $args ); + break; + + default: + trigger_error( sprintf( __( 'Unsupported HTTP method %s.', 'press-sync' ), $method ), E_USER_WARNING ); + return; + } + + $data['code'] = wp_remote_retrieve_response_code( $data['raw_response'] ); + + if ( 200 === $data['code'] ) { + $data['body'] = json_decode( wp_remote_retrieve_body( $data['raw_response'] ), true ); + } + + return $data; + } + + /** + * Get remote data from an API request. + * + * @since NEXT + * @param string $request The requested datapoint. + * @return array + */ + public static function get_remote_data( $request, $args = array() ) { + $args = wp_parse_args( $args, array( + 'press_sync_key' => get_option( 'ps_remote_key') + ) ); + + $url = API::get_remote_url( get_option( 'ps_remote_domain' ), $request, $args ); + $response = API::get_remote_response( $url, $args ); + + if ( empty( $response['body'] ) ) { + return array(); + } + + return $response['body']; + } + /** * Determine if we're syncing partial terms with the post object. * diff --git a/includes/class-cli.php b/includes/class-cli.php index 78af3ab..26cbf36 100644 --- a/includes/class-cli.php +++ b/includes/class-cli.php @@ -2,6 +2,9 @@ namespace Press_Sync; +use Press_Sync\client\cli\AbstractCliCommand; +use Press_Sync\client\cli\command\Validate; + /** * CLI Support for Press Sync. * @@ -17,6 +20,18 @@ class CLI { */ protected $plugin = null; + /** + * Commands registered to this plugin. + * + * @TODO Refactor everything currently into the constructor into standalone classes and add them to this array. + * + * @var array + * @since NEXT + */ + protected $commands = array( + Validate::class, + ); + /** * The constructor. * @@ -39,6 +54,23 @@ public function __construct( Press_Sync $plugin ) { \WP_CLI::add_command( 'press-sync options', array( $this, 'sync_options' ) ); } + /** + * Initialize concrete instances of AbstractCliCommand objects and register those commands w/ WP-CLI. + * + * @since NEXT + */ + public function init_commands() { + if ( ! class_exists( '\WP_CLI' ) ) { + return; + } + + foreach ( $this->commands as $command ) { + /* @var AbstractCliCommand $class */ + $class = new $command(); + $class->register_command(); + } + } + /** * Synchronize ALL content. * diff --git a/includes/class-dashboard.php b/includes/class-dashboard.php index 00a1690..f715053 100644 --- a/includes/class-dashboard.php +++ b/includes/class-dashboard.php @@ -153,6 +153,9 @@ public function register_settings() { register_setting( 'press-sync', 'ps_remote_query_args' ); register_setting( 'press-sync', 'ps_remote_key' ); + // Validation + register_setting( 'press-sync-validation', Validation::VALIDATION_OPTION ); + // Advanced page. foreach ( self::ADVANCED_OPTIONS as $option ) { register_setting( 'press-sync-advanced', $option ); @@ -232,4 +235,32 @@ public function sync_wp_data_via_ajax() { wp_send_json_success( $this->plugin->sync_object( $this->objects_to_sync, $settings, $this->next_page, true ) ); } + + /** + * Formats validation results for the Dashboard. + * + * @since NEXT + * @param array $validation_results Array of results to format. + * @return string + */ + public static function format_validation( array $validation_results ) { + $html = ''; + + foreach ( $validation_results as $heading => $results ) { + if ( empty( $results ) ) { + continue; + } + + $html .= sprintf( '

%s

', ucwords( $heading ) ); + $html .= ''; + + foreach ( $results as $result_row ) { + $html .= sprintf( '', $result_row ); + } + + $html .= '
%s
'; + } + + return $html; + } } diff --git a/includes/class-press-sync.php b/includes/class-press-sync.php index fc03bf7..d64260b 100644 --- a/includes/class-press-sync.php +++ b/includes/class-press-sync.php @@ -85,10 +85,10 @@ static function init() { * @since 0.1.0 */ public function hooks() { + $this->cli->init_commands(); add_filter( 'http_request_host_is_external', array( $this, 'approve_localhost_urls' ), 10, 3 ); add_filter( 'press_sync_order_to_sync_all', array( $this, 'order_to_sync_all' ), 10, 1 ); add_filter( 'press_sync_after_prepare_post_args_to_sync', array( $this, 'maybe_remove_post_id' ) ); - add_filter( 'press_sync_get_taxonomy_term_where', array( $this, 'maybe_get_terms_for_post' ) ); } @@ -194,23 +194,10 @@ public function init_connection( $remote_domain = '' ) { */ public function check_connection( $url = '' ) { - $url = $this->get_remote_url( $url ); - - $remote_get_args = array( - 'timeout' => 30, - ); - - $response = wp_remote_get( $url, $remote_get_args ); - $response_code = wp_remote_retrieve_response_code( $response ); - - if ( 200 === $response_code ) { - $response_body = json_decode( wp_remote_retrieve_body( $response ), true ); - - return isset( $response_body['success'] ) ? $response_body['success'] : false; - } - - return false; + $url = API::get_remote_url( $url ); + $response = API::get_remote_response( $url ); + return ! empty( $response['body']['success'] ); } /** @@ -328,13 +315,10 @@ public function get_users_to_sync( $next_page = 1 ) { if ( $results ) { - foreach ( $results as $user ) { - - // Get user role. - $role = $user->roles[0]; + foreach ( $results as $raw_user ) { // Get user data. - $user = (array) $user->data; + $user = (array) $raw_user->data; $user_meta = get_user_meta( $user['ID'] ); foreach ( $user_meta as $key => $value ) { @@ -343,7 +327,12 @@ public function get_users_to_sync( $next_page = 1 ) { $user['meta_input']['press_sync_user_id'] = $user['ID']; $user['meta_input']['press_sync_source'] = home_url(); - $user['role'] = $role; + $user['role'] = ''; + + if ( ! empty( $raw_user->roles ) ) { + // Get user role. + $user['role'] = $raw_user->roles[0]; + } unset( $user['ID'] ); @@ -815,29 +804,6 @@ public function prepare_comment_args_to_sync( $comment_args ) { return $args; } - /** - * POST data to the remote site. - * - * @since 0.1.0 - * - * @param string $url The url of the remote site. - * @param array $args The arguments to send to the remote site. - * - * @return JSON $response_body - */ - public function send_data_to_remote_site( $url, $args ) { - - $args = array( - 'timeout' => 30, - 'body' => $args, - ); - - $response = wp_remote_post( $url, $args ); - $response_body = wp_remote_retrieve_body( $response ); - - return $response_body; - } - /** * Find any embedded images in the post content. * @@ -1026,8 +992,10 @@ public function sync_batch( $content_type = 'post', $settings = array(), $next_p $this->init_connection( $settings['remote_domain'] ); // Build out the url and send the data to the remote site. - $url = $this->get_remote_url( '', 'sync' ); - $logs = $this->send_data_to_remote_site( $url, $objects_args ); + $url = API::get_remote_url( '', 'sync' ); + $logs = API::get_remote_response( $url, array( + 'body' => $objects_args, + ), 'POST' ); return array( 'objects_to_sync' => $content_type, @@ -1305,21 +1273,13 @@ private function is_404( $url ) { * @since 0.7.0 * @param array $args (Optional) Array of query parameters. * + * @deprecated NEXT Deprecated in favor of \Press_Sync\API::get_remote_url. + * * @return string */ public function get_remote_url( $url = '', $endpoint = 'status', $args = array() ) { - $url = $url ? trailingslashit( $url ) : trailingslashit( get_option( 'ps_remote_domain' ) ); - $query_args = wp_parse_args( $args, array( - 'press_sync_key' => get_option( 'ps_remote_key' ), - ) ); - - if ( $remote_args = get_option( 'ps_remote_query_args' ) ) { - $remote_args = ltrim( $remote_args, '?' ); - parse_str( $remote_args, $remote_args_array ); - $query_args = array_merge( $query_args, $remote_args_array ); - } - - return "{$url}wp-json/press-sync/v1/{$endpoint}?" . http_build_query( $query_args ); + _deprecated_function( Press_Sync::class . '::get_remote_url', 'NEXT', '\\Press_Sync\\API::get_remote_url' ); + return API::get_remote_url( $url, $endpoint, $args ); } /** @@ -1391,35 +1351,26 @@ public function get_synced_object_ids( $objects_to_sync ) { $option_name = "ps_synced_post_session_{$objects_to_sync}"; $last_sync = get_option( $option_name ); - if ( is_array( $last_sync ) && ! empty( $last_sync ) ) { + if ( ! empty( $last_sync ) && is_array( $last_sync ) ) { return $last_sync; } - $url = $this->get_remote_url( '', 'progress', array( + $url = API::get_remote_url( '', 'progress', array( 'post_type' => $objects_to_sync, 'preserve_ids' => (bool) get_option( 'ps_preserve_ids' ), ) ); - $remote_get_args = array( - 'timeout' => 30, - ); - - $response = wp_remote_get( $url, $remote_get_args ); - $response_code = wp_remote_retrieve_response_code( $response ); + $response = API::get_remote_response( $url ); - if ( 200 !== $response_code ) { + if ( 200 !== $response['code'] || empty( $response['body']['data']['synced'] ) ) { return array(); } - $response_body = json_decode( wp_remote_retrieve_body( $response ), true ); - - if ( empty( $response_body['data']['synced'] ) ) { - return array(); - } + $synced = $response['body']['data']['synced']; - update_option( $option_name, $response_body['data']['synced'] ); + update_option( $option_name, $synced ); - return $response_body['data']['synced']; + return $synced; } /** diff --git a/includes/class-validation.php b/includes/class-validation.php new file mode 100644 index 0000000..d5e5105 --- /dev/null +++ b/includes/class-validation.php @@ -0,0 +1,106 @@ + array( + 'label' => 'Posts', + 'description' => 'Validate a sample of published Posts.', + ), + 'users' => array( + 'label' => 'Users', + 'description' => 'Validate a random sample of users across different roles.', + ), + ); + + /** + * The type of validation operation we're currently doing. + * + * @since NEXT + * @var bool + */ + private static $validation_type = false; + + /** + * Check to see if we're running a validation. + * + * @since NEXT + * @return bool + */ + public static function is_validating() { + $current_validation = get_option( self::VALIDATION_OPTION ); + + if ( ! $current_validation ) { + return false; + } + + // If we're validating, find out what we're validating and delete the option. + $current_validation = explode( ' ', strtolower( $current_validation ) ); + self::$validation_type = array_pop( $current_validation ); + delete_option( self::VALIDATION_OPTION ); + + return true; + } + + /** + * Static call method to return private variables. + * + * @since NEXT + * @param string $method The static method being called. + * @param array $args Arguments passed to the method. + * @return mixed + */ + public static function __callStatic( $method, $args ) { + if ( 'get_' !== substr( $method, 0, 4 ) ) { + return; + } + + $what = substr( $method, 4 ); + + if ( ! isset( self::${$what} ) ) { + return; + } + + return self::${$what}; + } + + /** + * Get results of a validation request. + * + * @since NEXT + * @return string + */ + public static function get_validation_results() { + $validation_class = '\Press_Sync\validators\\' . ucwords( self::$validation_type, '_' ); // Validate_Users + $validator = new $validation_class(); + return $validator->compare_results(); + } +} diff --git a/includes/client/AbstractOutput.php b/includes/client/AbstractOutput.php new file mode 100644 index 0000000..fd3e229 --- /dev/null +++ b/includes/client/AbstractOutput.php @@ -0,0 +1,38 @@ +data = $data; + } + + /** + * Get an icon based on whether a result was (bool) true or not. + * + * @since NEXT + * + * @param bool $result The result to test. + * + * @return string + */ + protected function get_result_icon( $result ) { + return ( (bool) $result ) === true ? '✅' : '❌'; + } +} diff --git a/includes/client/OutputInterface.php b/includes/client/OutputInterface.php new file mode 100644 index 0000000..2ab8dbf --- /dev/null +++ b/includes/client/OutputInterface.php @@ -0,0 +1,17 @@ + PostSubcommand::class, + 'taxonomies' => TaxonomySubcommand::class, + 'users' => UserSubcommand::class, + ); + + /** + * Register our custom commands with WP-CLI. + * + * @since NEXT + */ + public function register_command() { + \WP_CLI::add_command( 'press-sync validate', array( $this, 'validate' ) ); + } + + /** + * Validate data consistency between source site and destination site. + * + * ## OPTIONS + * + * + * : The type of entity to validate. + * options: + * - posts + * - taxonomies + * + * @param array $args Command arguments. + * @param array $assoc_args Command associative arguments. + * + * @synopsis [--remote_domain=] [--remote_press_sync_key=] [--count=] + * @since NEXT + + * @return void + */ + public function validate( $args, $assoc_args ) { + if ( empty( $args ) ) { + \WP_CLI::warning( 'You must choose an entity type to validate.' ); + return; + } + + $validation_entity = filter_var( $args[0], FILTER_SANITIZE_STRING ); + + if ( ! isset( $this->subcommands[ $validation_entity ] ) ) { + \WP_CLI::warning( "{$validation_entity} is not a valid entity type." ); + return; + } + + // Call the method in this class that handles the selected entity to validate. + /* @var $subcommand AbstractValidateSubcommand */ + $subcommand = new $this->subcommands[ $validation_entity ]( $assoc_args ); + $subcommand->validate(); + } +} diff --git a/includes/client/cli/command/validate/AbstractValidateSubcommand.php b/includes/client/cli/command/validate/AbstractValidateSubcommand.php new file mode 100644 index 0000000..d001636 --- /dev/null +++ b/includes/client/cli/command/validate/AbstractValidateSubcommand.php @@ -0,0 +1,66 @@ +getMessage() ); + } + } + + /** + * Gets the output data formatting for CLI commands. + * + * @since NEXT + * @return array + */ + public function get_data_output_format() { + return array( + 'match_open_wrap' => '%G', + 'match_close_wrap' => '%n', + 'mismatch_open_wrap' => '%R', + 'mismatch_close_wrap' => '%n', + ); + } +} diff --git a/includes/client/cli/command/validate/PostSubcommand.php b/includes/client/cli/command/validate/PostSubcommand.php new file mode 100644 index 0000000..55f512d --- /dev/null +++ b/includes/client/cli/command/validate/PostSubcommand.php @@ -0,0 +1,60 @@ +args = $args; + $this->validator = new PostValidator( array( + 'sample_count' => $args['count'] ?? 5, + 'format' => $this->get_data_output_format(), + ) ); + } + + /** + * Get validation data for Post entity. + * + * @throws ExitException Throw exception if --url argument is missing on multisite. + * @since NEXT + */ + public function validate() { + $this->check_multisite_params(); + + $data = $this->validator->validate(); + $output_data = array(); + + foreach ( $data as $data_location => $location ) { + foreach ( $location as $data_set => $values ) { + $output_data[ $data_set ][ $data_location ] = $values; + } + } + + foreach ( $output_data as $key => $datum ) { + /* @var $output OutputInterface */ + $output_renderer = PostRenderFactory::create( $key, $datum ); + + if ( ! is_wp_error( $output_renderer ) ) { + $output_renderer->render(); + continue; + } + + \WP_CLI::error( $output_renderer->get_error_message() ); + } + } +} diff --git a/includes/client/cli/command/validate/TaxonomySubcommand.php b/includes/client/cli/command/validate/TaxonomySubcommand.php new file mode 100644 index 0000000..9af0cfd --- /dev/null +++ b/includes/client/cli/command/validate/TaxonomySubcommand.php @@ -0,0 +1,59 @@ +args = $args; + $this->validator = new TaxonomyValidator( array( + 'sample_count' => $args['count'] ?? 5, + 'format' => $this->get_data_output_format(), + ) ); + } + /** + * Get validation data for the Taxonomy entity. + * + * @throws ExitException Exception if url parameter is not passed in multisite. + * @since NEXT + */ + public function validate() { + $this->check_multisite_params(); + + $data = $this->validator->validate(); + $output_data = array(); + + foreach ( $data as $data_location => $location ) { + foreach ( $location as $data_set => $values ) { + $output_data[ $data_set ][ $data_location ] = $values; + } + } + + foreach ( $output_data as $key => $datum ) { + /* @var $output OutputInterface */ + $output_renderer = TaxonomyRenderFactory::create( $key, $datum ); + + if ( ! is_wp_error( $output_renderer ) ) { + $output_renderer->render(); + continue; + } + + \WP_CLI::error( $output_renderer->get_error_message() ); + } + } +} diff --git a/includes/client/cli/command/validate/UserSubcommand.php b/includes/client/cli/command/validate/UserSubcommand.php new file mode 100644 index 0000000..1ab7de8 --- /dev/null +++ b/includes/client/cli/command/validate/UserSubcommand.php @@ -0,0 +1,67 @@ +args = $args; + $this->validator = new UserValidator( array( + 'sample_count' => 2, + 'format' => $this->get_data_output_format(), + ) ); + } + + /** + * Get validation data for the Taxonomy entity. + * + * @throws ExitException Exception if url parameter is not passed in multisite. + * @since NEXT + */ + public function validate() { + $this->check_multisite_params(); + + $data = $this->validator->validate(); + + foreach ( $data['destination']['count'] as $role => $count ) { + $data['destination']['count'][ $role ] = \WP_CLI::colorize( $data['comparison']['count'][ $role ] ); + } + + $this->output( $data['source']['count'], 'Local User Counts' ); + $this->output( $data['destination']['count'], 'Remote User Counts' ); + + // @TODO output sample data. + // echo '
', print_r($data['samples']['destination'], true); die("G");
+		#echo '
', print_r($data['samples'], true); die;
+	}
+
+	/**
+	 * Output data in the CLI.
+	 *
+	 * @param array  $data    Data to output.
+	 * @param string $message Optional message to render.
+	 * @since NEXT
+	 */
+	private function output( $data, $message = '' ) {
+		if ( $message ) {
+			\WP_CLI::line( $message );
+		}
+
+		$format = 'table';
+		$fields = array_keys( $data );
+		$assoc_args = compact( 'format', 'fields' );
+		$formatter = new \WP_CLI\Formatter( $assoc_args );
+		$formatter->display_items( array( $data ), true );
+	}
+}
diff --git a/includes/client/cli/command/validate/ValidationOutputInterface.php b/includes/client/cli/command/validate/ValidationOutputInterface.php
new file mode 100644
index 0000000..ffa9bb0
--- /dev/null
+++ b/includes/client/cli/command/validate/ValidationOutputInterface.php
@@ -0,0 +1,20 @@
+data = $this->prepare_colorized_output( $this->data );
+		$this->output( $this->prepare( $this->data['source'] ), 'Local post counts by type and status:' );
+		$this->output( $this->prepare( $this->data['destination'] ), 'Remote post counts by type and status:' );
+	}
+
+	/**
+	 * Output data to the CLI.
+	 *
+	 * @param array  $data    Array of post data.
+	 * @param string $message Optional message to print before the data table.
+	 *
+	 * @since NEXT
+	 */
+	public function output( $data, $message = '' ) {
+		if ( $message ) {
+			\WP_CLI::line( $message );
+		}
+
+		$format     = 'table';
+		$fields     = array_keys( $data[0] );
+		$assoc_args = compact( 'format', 'fields' );
+		$formatter  = new Formatter( $assoc_args );
+		$formatter->display_items( $data, true );
+	}
+
+	/**
+	 *
+	 */
+	public function prepare( $post_data ) {
+		$table_values = array();
+
+		foreach ( $post_data as $post_type => $post_status_count ) {
+			$new_array              = array();
+			$new_array['post_type'] = $post_type;
+
+			foreach ( $post_status_count as $status => $count ) {
+				$new_array[ $status ] = $count;
+			}
+
+			$table_values[] = $new_array;
+		}
+
+		return $table_values;
+	}
+
+	/**
+	 * Prepare colorized output of value differences on destination site.
+	 *
+	 * @param array $data Data to colorize.
+	 *
+	 * @return array
+	 * @since NEXT
+	 */
+	private function prepare_colorized_output( $data ) {
+		foreach ( $data['destination'] as $post_type => $status ) {
+			foreach ( $status as $status_name => $count ) {
+				$data['destination'][ $post_type ][ $status_name ] = \WP_CLI::colorize( $data['comparison'][ $post_type ][ $status_name ] );
+			}
+		}
+
+		return $data;
+	}
+}
diff --git a/includes/client/cli/output/PostRenderFactory.php b/includes/client/cli/output/PostRenderFactory.php
new file mode 100644
index 0000000..a7b1adf
--- /dev/null
+++ b/includes/client/cli/output/PostRenderFactory.php
@@ -0,0 +1,30 @@
+output( $this->prepare( $this->data['comparison'] ), 'Sample destination post data matches local?' );
+		\WP_CLI::line();
+	}
+
+	/**
+	 * Output the data in a table.
+	 *
+	 * @param array  $data Data to output.
+	 * @param string $message Message to display with the output.
+	 */
+	public function output( $data, $message = '' ) {
+		if ( $message ) {
+			\WP_CLI::line( $message );
+		}
+
+		$format     = 'table';
+		$fields     = array_keys( $data[0] );
+		$assoc_args = compact( 'format', 'fields' );
+		$formatter  = new Formatter( $assoc_args );
+		$formatter->display_items( $data, true );
+	}
+
+	/**
+	 * Prepare the table to be output in the CLI.
+	 *
+	 * @param array $data Data to prepare for output.
+	 *
+	 * @return array
+	 */
+	public function prepare( array $data ) {
+		foreach ( $data as $index => $post_sample ) {
+			foreach ( $post_sample as $key => $value ) {
+				if ( 'post_id' === $key ) {
+					$data[ $index ][ $key ] = $value;
+
+					continue;
+				}
+
+				$data[ $index ][ $key ] = \WP_CLI::colorize( $value );
+			}
+		}
+
+		return $data;
+	}
+}
diff --git a/includes/client/cli/output/PostSampleTax.php b/includes/client/cli/output/PostSampleTax.php
new file mode 100644
index 0000000..c601e43
--- /dev/null
+++ b/includes/client/cli/output/PostSampleTax.php
@@ -0,0 +1,55 @@
+output( $this->prepare( $this->data['comparison'] ), 'Sample post taxonomy comparison:' );
+	}
+
+	/**
+	 * Output data to the CLI.
+	 *
+	 * @param array  $data    Array of post data.
+	 * @param string $message Optional message to print before the data table.
+	 *
+	 * @since NEXT
+	 */
+	public function output( array $data, $message = '' ) {
+		if ( $message ) {
+			\WP_CLI::line( $message );
+		}
+
+		$format     = 'table';
+		$fields     = array_keys( current( $data ) );
+		$assoc_args = compact( 'format', 'fields' );
+		$formatter  = new Formatter( $assoc_args );
+		$formatter->display_items( $data, true );
+	}
+
+	/**
+	 * Prepare data for rendering to the client.
+	 *
+	 * @param array $data Data to prepare.
+	 *
+	 * @return array $data
+	 */
+	public function prepare( $data ) {
+		foreach ( $data as $key => $post_tax_data ) {
+			$data[ $key ]['terms_migrated'] = \WP_CLI::colorize( $data[ $key ]['terms_migrated'] );
+		}
+
+		return $data;
+	}
+}
diff --git a/includes/client/cli/output/TaxonomyCount.php b/includes/client/cli/output/TaxonomyCount.php
new file mode 100644
index 0000000..61eb22e
--- /dev/null
+++ b/includes/client/cli/output/TaxonomyCount.php
@@ -0,0 +1,61 @@
+output( $this->prepare( $this->data['comparison'] ), 'Taxonomy terms count:' );
+		\WP_CLI::log( 'Number of unique taxonomies: ' . count( $this->data['comparison'] ) );
+		\WP_CLI::line();
+
+		// $this->output_comparison_statements( $this->data['source'], $this->data['destination'] );
+	}
+
+	/**
+	 * Output data in the CLI.
+	 *
+	 * @param array  $data    Data to output.
+	 * @param string $message Optional message to render.
+	 * @since NEXT
+	 */
+	private function output( $data, $message = '' ) {
+		if ( $message ) {
+			\WP_CLI::line( $message );
+		}
+
+		$format     = 'table';
+		$fields     = array_keys( $data['category'] );
+		$assoc_args = compact( 'format', 'fields' );
+		$formatter  = new Formatter( $assoc_args );
+		$formatter->display_items( $data, true );
+	}
+
+	/**
+	 * Prepare the data for rendering.
+	 *
+	 * @param array $data Data to prepare for rendering.
+	 *Ø
+	 * @return array
+	 */
+	public function prepare( array $data ) {
+		foreach ( $data as $taxonomy_name => $taxonomy ) {
+			foreach ( $taxonomy as $index => $value ) {
+				if ( 'destination_count' === $index || 'migrated' === $index ) {
+					$data[ $taxonomy_name ][ $index ] = \WP_CLI::colorize( $value );
+				}
+			}
+		}
+
+		return $data;
+	}
+}
diff --git a/includes/client/cli/output/TaxonomyRenderFactory.php b/includes/client/cli/output/TaxonomyRenderFactory.php
new file mode 100644
index 0000000..8800848
--- /dev/null
+++ b/includes/client/cli/output/TaxonomyRenderFactory.php
@@ -0,0 +1,28 @@
+output( $this->prepare( $this->data['comparison'] ), 'Post counts by sample taxonomy term:' );
+		\WP_CLI::line();
+	}
+
+	/**
+	 * @param array  $data
+	 * @param string $message
+	 */
+	public function output( array $data, $message = '' ) {
+		if ( $message ) {
+			\WP_CLI::line( $message );
+		}
+
+		$format     = 'table';
+		$fields     = array_keys( current( $data ) );
+		$assoc_args = compact( 'format', 'fields' );
+		$formatter  = new Formatter( $assoc_args );
+		$formatter->display_items( $data, true );
+	}
+
+	/**
+	 * @param array $data
+	 *
+	 * @return array
+	 */
+	public function prepare( array $data ) {
+		foreach ( $data as $index => $post_sample ) {
+			foreach ( $post_sample as $key => $value ) {
+				if ( 'destination_count' === $key || 'migrated' === $key ) {
+					$data[ $index ][ $key ] = \WP_CLI::colorize( $value );
+				}
+			}
+		}
+
+		return $data;
+	}
+}
diff --git a/includes/validation/CountInterface.php b/includes/validation/CountInterface.php
new file mode 100644
index 0000000..cc3176e
--- /dev/null
+++ b/includes/validation/CountInterface.php
@@ -0,0 +1,6 @@
+get_sample_posts_data( $count );
+
+		return array(
+			'count'      => $this->get_count(),
+			'sample'     => $posts,
+			'sample_tax' => $this->get_sample_terms_data( $posts ),
+		);
+	}
+
+	/**
+	 * Get the number of posts for all registered post types.
+	 *
+	 * @return array
+	 * @since NEXT
+	 */
+	public function get_count() {
+		$posts = array();
+
+		foreach ( get_post_types() as $type ) {
+			$posts[ $type ] = wp_count_posts( $type );
+		}
+
+		return $posts;
+	}
+
+	/**
+	 * @param int $count
+	 *
+	 * @return array
+	 */
+	private function get_random_posts( $count = 5 ) {
+		$query = new \WP_Query( array(
+			'post_type'      => 'any',
+			'posts_per_page' => $count ?? 5,
+			'orderby'        => 'rand', // @codingStandardsIgnoreLine
+		) );
+
+		$posts = $query->get_posts();
+
+		// Sort posts by post ID.
+		usort( $posts, [ $this, 'sort_posts_by_id' ] );
+
+		wp_reset_postdata();
+
+		return $this->format_sample_post_data( $posts );
+	}
+
+	/**
+	 * Get a collection of posts to use for comparison against a sample.
+	 *
+	 * @since NEXT
+	 *
+	 * @param array $ids Array of post IDs.
+	 *
+	 * @return array
+	 */
+	public function get_comparison_posts( array $ids ) {
+		$query = new \WP_Query( array(
+			'post_type'      => 'any',
+			'posts_per_page' => count( $ids ),
+			'post__in'       => $ids,
+		) );
+
+		$posts = $query->get_posts();
+
+		usort( $posts, [ $this, 'sort_posts_by_id' ] );
+
+		wp_reset_postdata();
+
+		return $this->format_sample_post_data( $posts );
+	}
+
+	/**
+	 * @param $a
+	 * @param $b
+	 *
+	 * @return int
+	 */
+	private function sort_posts_by_id( $a, $b ) {
+		return strcmp( $a->ID, $b->ID );
+	}
+
+	/**
+	 * Get a sample number of posts.
+	 *
+	 * @param int $count
+	 */
+	public function get_sample_posts_data( $count = 5 ) {
+		return $this->get_random_posts( $count );
+	}
+
+
+
+	/**
+	 * Get the taxonomy term assignments for a random sample of posts.
+	 *
+	 * @param array $posts
+	 *
+	 * @return array
+	 */
+	public function get_sample_terms_data( array $posts ) {
+		return $this->format_sample_post_terms( $posts );
+	}
+
+	/**
+	 * Compare the taxonomy term assignments for a set of posts.
+	 *
+	 * @param array $ids Array of post IDs.
+	 *
+	 * @return array
+	 */
+	public function get_comparison_terms( array $ids ) {
+		return $this->format_sample_post_terms( $this->get_comparison_posts( $ids ) );
+	}
+
+	/**
+	 * @param $posts
+	 *
+	 * @return array
+	 */
+	public function format_sample_post_data( $posts ) {
+		$data = array();
+
+		foreach ( $posts as $post ) {
+			$author = get_userdata( $post->post_author );
+			$data[] = array(
+				'ID'      => $post->ID,
+				'type'    => $post->post_type,
+				'author'  => $author->user_login,
+				'content' => $post->post_content,
+				'meta'    => get_post_meta( $post->ID ),
+			);
+		}
+
+		return $data;
+	}
+
+	/**
+	 * @param array $posts
+	 */
+	public function format_sample_post_terms( array $posts ) {
+		$post_terms = array();
+
+		foreach ( $posts as $key => $post ) {
+			$terms = array(
+				'ID'    => $post['ID'],
+				'terms' => $this->get_post_terms( $post['ID'] ),
+			);
+
+			$post_terms[] = $terms;
+		}
+
+		return $post_terms;
+	}
+
+	/**
+	 * @param $post_id
+	 *
+	 * @return array
+	 */
+	private function get_post_terms( $post_id ) {
+		$post_terms = array();
+
+		foreach ( get_taxonomies() as $taxonomy ) {
+			$terms = wp_get_post_terms( $post_id, $taxonomy, array( 'fields' => 'slugs' ) );
+
+			if ( $terms ) {
+				$post_terms[ $taxonomy ] = $terms;
+			}
+		}
+
+		return $post_terms;
+	}
+}
diff --git a/includes/validation/Taxonomy.php b/includes/validation/Taxonomy.php
new file mode 100644
index 0000000..6682ded
--- /dev/null
+++ b/includes/validation/Taxonomy.php
@@ -0,0 +1,142 @@
+ $this->get_count(),
+			'post_terms' => $this->get_post_count_by_taxonomy_term(),
+		);
+	}
+
+	/**
+	 * Get the number of unique taxonomies in this WordPress installation.
+	 *
+	 * @return int
+	 * @since NEXT
+	 */
+	public function get_unique_taxonomy_count() {
+		return count( get_taxonomies() );
+	}
+
+	/**
+	 * Get the number of terms for each taxonomy in this WordPress installation.
+	 *
+	 * @return array
+	 * @since NEXT
+	 */
+	public function get_term_count_by_taxonomy() {
+		$terms = [];
+
+		foreach ( get_taxonomies() as $name => $taxonomy ) {
+			$taxonomy_terms = get_terms(
+				array(
+					'taxonomy'   => $taxonomy,
+					'hide_empty' => false,
+				)
+			);
+
+			$terms[ $name ] = array(
+				'number_of_terms' => count( $taxonomy_terms ),
+			);
+		}
+
+		return $terms;
+	}
+
+	/**
+	 * Get an indexed array of post counts by taxonomy and term.
+	 *
+	 * @TODO Let's split up some of these responsibilities.
+	 *
+	 * @return array
+	 * @since NEXT
+	 */
+	public function get_post_count_by_taxonomy_term() {
+		$term_query = new \WP_Term_Query( array(
+			'hide_empty' => false,
+		) );
+
+		$terms = $term_query->get_terms();
+
+		$data = array();
+
+		foreach ( $terms as $term ) {
+			$data[ $term->slug . '-' . $term->taxonomy ] = array(
+				'taxonomy' => $term->taxonomy,
+				'count'    => $term->count,
+				'slug'     => $term->slug,
+			);
+		}
+
+		wp_reset_postdata();
+
+		return $data;
+	}
+
+
+	/**
+	 * Get taxonomy-related counts.
+	 *
+	 * @return array
+	 * @since NEXT
+	 */
+	public function get_count() {
+		return array(
+			'unique_taxonomies'      => $this->get_unique_taxonomy_count(),
+			'term_count_by_taxonomy' => $this->get_term_count_by_taxonomy(),
+		);
+	}
+
+	/**
+	 * @param int $count Number of terms to process.
+	 *
+	 * @return array
+	 */
+	public function get_sample_meta() {
+		$data = array();
+
+		foreach ( get_taxonomies() as $taxonomy ) {
+			$terms                          = get_terms( array( 'taxonomy' => $taxonomy ) );
+			$data[ $taxonomy ]['terms']     = wp_list_pluck( $terms, 'slug' );
+			$data[ $taxonomy ]['term_meta'] = $this->get_taxonomy_term_meta( $terms );
+		}
+
+		return $data;
+	}
+
+	/**
+	 * Get the term meta from an array of terms.
+	 *
+	 * @param array $terms Term objects.
+	 *
+	 * @return array
+	 */
+	private function get_taxonomy_term_meta( $terms ) {
+		$term_meta = array();
+
+		foreach ( $terms as $term ) {
+			$term_meta[ $term->slug ] = array();
+
+			$meta = get_term_meta( $term->term_id );
+
+			if ( $meta ) {
+				$term_meta[ $term->slug ] = $meta;
+			}
+		}
+
+		return $term_meta;
+	}
+}
diff --git a/includes/validation/User.php b/includes/validation/User.php
new file mode 100644
index 0000000..054d9a8
--- /dev/null
+++ b/includes/validation/User.php
@@ -0,0 +1,139 @@
+ 5,
+		) );
+
+		foreach ( $args as $key => $value ) {
+			if ( isset( $this->{$key} ) ) {
+				$this->{$key} = $value;
+			}
+		}
+	}
+
+	/**
+	 * Get the number of users in the WordPress install.
+	 */
+	public function get_count() {
+		$counts            = count_users();
+		$prepared          = $counts['avail_roles'];
+		$prepared['total'] = $counts['total_users'];
+
+		return $prepared;
+	}
+
+	/**
+	 * Returns a random sample of data for validation.
+	 *
+	 * @since NEXT
+	 * @return array
+	 */
+	public function get_sample( $request = null ) {
+		if ( $request ) {
+			$source_users = $request->get_param( 'source_users' );
+			return $this->find_sample_matches( $source_users );
+		}
+
+		return $this->get_local_samples();
+	}
+
+	/**
+	 * Looks for matches for the given sample data.
+	 *
+	 * @since NEXT
+	 * @param  array $samples The array of sample data to find matches for.
+	 * @return array
+	 *
+	 * @TODO This needs to be cleaned up!
+	 */
+	private function find_sample_matches( $samples ) {
+		$results = array();
+		$meta_key = 'press_sync_user_id';
+
+		if ( is_multisite() ) {
+			global $blog_id;
+			$meta_key = "press_sync_{$blog_id}_user_id";
+		}
+
+		foreach ( $samples as $args ) {
+			// Strongest match is all three matching.
+			$user_args = array(
+				'meta_key'   => $meta_key,
+				'meta_value' => $args['ID'],
+				'login'      => $args['user_login'],
+				'user_email' => $args['user_email'],
+			);
+
+			$user_args = array_filter( $user_args );
+
+			$query = new \WP_User_Query( $user_args );
+			$users = $query->get_results();
+
+			if ( ! $users ) {
+				unset( $user_args['meta_key'] );
+				unset( $user_args['meta_value'] );
+			}
+
+			while ( empty( $users ) && count( $user_args ) ) {
+				$query = new \WP_User_Query( $user_args );
+				$users = $query->get_results();
+
+				if ( empty( $users ) ) {
+					array_shift( $user_args );
+				}
+			}
+
+			if ( ! count( $users ) ) {
+				$user = [];
+			} else {
+				$user = (array) $users[0];
+			}
+
+			$user['source_data']    = $args;
+			$user['matched_fields'] = $user_args;
+			$results[]              = $user;
+		}
+
+		return $results;
+	}
+
+	/**
+	 * Gets local sample data.
+	 *
+	 * @since NEXT
+	 * @return array
+	 */
+	public function get_local_samples() {
+		$count   = absint( $this->sample_count );
+		$users   = get_users();
+		$samples = array();
+
+		for ( $count; $count--; ) {
+			$offset    = rand(0, count( $users ) ) - 1;
+			$samples[] = current( array_slice( $users, $offset, 1 ) );
+		}
+
+		return $samples;
+	}
+
+	public function get_data() {
+		return array(
+			'count' => $this->get_count(),
+			'sample' => $this->get_sample(),
+		);
+	}
+}
diff --git a/includes/validation/ValidationUtility.php b/includes/validation/ValidationUtility.php
new file mode 100644
index 0000000..a26701e
--- /dev/null
+++ b/includes/validation/ValidationUtility.php
@@ -0,0 +1,28 @@
+ array(
+	 *        'key' => 'row',
+	 *    )
+	 * )
+	 *
+	 * Where:
+	 * - 'what' Will be used as the result section heading.
+	 * - 'key' Is currently unused, should be a unique key for this row of data.
+	 * - 'row' A formatted message about the results for the comparison.
+	 *
+	 * Example:
+	 * array(
+	 *     'counts' => array(
+	 *         'published_posts' => '✅ Published posts count is 24,331 vs 24,331.',
+	 *         'draft_posts'     => '❌ Draft posts count is 5 vs 3.',
+	 *     ),
+	 *     'samples' => array(
+	 *         177232 => '✅ The post "Some Test Post" matches 1:1 with the destination site.',
+	 *         175300 => '❌ The post "Another Post" differs between source and destination: ',
+	 *     )
+	 * )
+	 *
+	 * @since NEXT
+	 */
+	public function validate();
+
+	/**
+	 * This method should gather data from the source site for comparison.
+	 *
+	 * @since NEXT
+	 */
+	public function get_source_data();
+
+	/**
+	 * This method should gather data from the destination site for comparison.
+	 *
+	 * @since NEXT
+	 */
+	public function get_destination_data();
+}
diff --git a/includes/validators/AbstractValidator.php b/includes/validators/AbstractValidator.php
new file mode 100644
index 0000000..39729c1
--- /dev/null
+++ b/includes/validators/AbstractValidator.php
@@ -0,0 +1,109 @@
+args = $args;
+
+		if ( ! isset( $args['format'] ) ) {
+			throw new \InvalidArgumentException( __( get_called_class() . ' missing required argument "format"!', 'press-sync' ) );
+		}
+	}
+
+	/**
+	 * Magic getter.
+	 *
+	 * @since NEXT
+	 * @param  string $key The property key to grab.
+	 * @return mixed
+	 */
+	public function __get( $key ) {
+		if ( isset( $this->key ) ) {
+			return $this->key;
+		}
+	}
+
+	/**
+	 * Compares source and destination data.
+	 *
+	 * @since NEXT
+	 * @param  array $source      The source dataset.
+	 * @param  array $destination The destination dataset.
+	 * @return array
+	 */
+	abstract public function get_comparison_data( array $source, array $destination );
+
+	/**
+	 * Determine if two values are the same and wrap them in appropriate formatting.
+	 *
+	 * @since NEXT
+	 *
+	 * @param  mixed $count             The first count to compare.
+	 * @param  mixed $compare           The count to compare against the first.
+	 * @param mixed  $value_to_colorize Optional alternate value to colorize.
+	 *
+	 * @return string
+	 */
+	protected function apply_diff_to_values( $count, $compare, $value_to_colorize = null ) {
+		$format            = $this->args['format'];
+		$pre               = $format['match_open_wrap'];
+		$post              = $format['match_close_wrap'];
+		$value_to_colorize = $value_to_colorize ? $value_to_colorize : $compare;
+
+		if ( $count !== $compare ) {
+			$pre  = $format['mismatch_open_wrap'];
+			$post = $format['mismatch_close_wrap'];
+		}
+
+		return "{$pre}{$value_to_colorize}{$post}";
+	}
+}
diff --git a/includes/validators/PostValidator.php b/includes/validators/PostValidator.php
new file mode 100644
index 0000000..5e899c1
--- /dev/null
+++ b/includes/validators/PostValidator.php
@@ -0,0 +1,317 @@
+source_data      = $this->get_source_data();
+		$this->destination_data = $this->get_destination_data();
+
+		$this->normalize_sample_for_comparison( 'sample', $this->source_data['sample'], $this->destination_data['sample'] );
+		$this->normalize_sample_for_comparison( 'sample_tax', $this->source_data['sample_tax'], $this->destination_data['sample_tax'] );
+
+		return array(
+			'source'      => $this->source_data,
+			'destination' => $this->destination_data,
+			'comparison'  => $this->get_comparison_data( $this->source_data, $this->destination_data ),
+		);
+	}
+
+	/**
+	 * Get taxonomy data from the local WordPress installation.
+	 *
+	 * @return array
+	 * @since NEXT
+	 */
+	public function get_source_data() {
+		return ( new Post() )->get_data( $this->args['sample_count'] );
+	}
+
+	/**
+	 * Get taxonomy data from the remote WordPress installation.
+	 *
+	 * @return array
+	 * @since NEXT
+	 */
+	public function get_destination_data() {
+		return array(
+			'count'      => API::get_remote_data( 'validation/post/count' ),
+			'sample'     => API::get_remote_data(
+				'validation/post/sample',
+				array(
+					'type' => 'posts',
+					'ids'  => $this->get_source_sample_ids(),
+				)
+			),
+			'sample_tax' => API::get_remote_data(
+				'validation/post/sample',
+				array(
+					'type' => 'terms',
+					'ids'  => $this->get_source_sample_ids(),
+				)
+			),
+		);
+	}
+
+	/**
+	 * Get the sample post IDs from the source data.
+	 *
+	 * These same IDs should be compared on the destination site.
+	 *
+	 * @return array
+	 */
+	private function get_source_sample_ids() {
+		$ids = array();
+
+		foreach ( $this->source_data['sample'] as $post ) {
+			$ids[] = $post['ID'];
+		}
+
+		return $ids;
+	}
+
+	/**
+	 * Compare source and destination data.
+	 *
+	 * @param array $source      Source data.
+	 * @param array $destination Destination data.
+	 *
+	 * @return array
+	 * @since NEXT
+	 */
+	public function get_comparison_data( array $source, array $destination ) {
+		return array(
+			'count'      => $this->compare_count( $source['count'], $destination['count'] ),
+			'sample'     => $this->compare_sample( $source['sample'], $destination['sample'] ),
+			'sample_tax' => $this->compare_sample_tax( $source['sample_tax'], $destination['sample_tax'] ),
+		);
+	}
+
+	/**
+	 * Compare counts between source and destination.
+	 *
+	 * @since NEXT
+	 * @param  array $source      Source counts.
+	 * @param  array $destination Destination counts.
+	 * @return array
+	 */
+	private function compare_count( $source, $destination ) {
+		$comparison = array();
+
+		foreach ( $source as $post_type => $source_statuses ) {
+			foreach ( $source_statuses as $source_status => $source_status_count ) {
+				$destination_status = 0;
+
+				if ( isset( $destination[ $post_type ][ $source_status ] ) ) {
+					$destination_status = $destination[ $post_type ][ $source_status ];
+				}
+
+				$comparison[ $post_type ][ $source_status ] = $this->apply_diff_to_values( $source_status_count, $destination_status );
+			}
+		}
+
+		return $comparison;
+	}
+
+	/**
+	 * We ran into an issue where posts were not being properly compared because their indexes were out of sync
+	 * because destination results might not match source results. These blocks re-key the source and destination
+	 * data by post ID, then populates the destination array with empty data for each missing post. This allows
+	 * us to compare values correctly by looping through the source_index.
+	 *
+	 * @param array $source      Local post data.
+	 * @param array $destination Remote post data.
+	 */
+	private function normalize_sample_for_comparison( $data_index, $source, $destination ) {
+		$source_index      = $this->index_data_by_post_id( $source );
+		$destination_index = $this->index_data_by_post_id( $destination );
+
+		foreach ( $source_index as $key => $source_post ) {
+			$source_index[ $key ]['migrated'] = 'yes';
+
+			if ( isset( $destination_index[ $key ] ) ) {
+				$destination_index[ $key ]['migrated'] = 'yes';
+
+				continue;
+			}
+
+			foreach ( $source_post as $index => $value ) {
+				if ( 'ID' === $index ) {
+					$destination_index[ $key ][ $index ] = $key;
+
+					continue;
+				}
+
+				$destination_index[ $key ][ $index ] = null;
+			}
+
+			$destination_index[ $key ]['migrated'] = 'no';
+		}
+
+		$this->source_data[ $data_index ]      = $this->index_data_by_post_id( array_values( $source_index ) );
+		$this->destination_data[ $data_index ] = $this->index_data_by_post_id( array_values( $destination_index ) );
+	}
+
+	/**
+	 * Take an array of post data and update it to be an associative array indexed by post ID.
+	 *
+	 * @param array $post_data Array of post data.
+	 *
+	 * @return array
+	 */
+	private function index_data_by_post_id( $post_data ) {
+		$data = array();
+
+		foreach ( $post_data as $post ) {
+			$data[ $post['ID'] ] = $post;
+		}
+
+		return $data;
+	}
+
+	/**
+	 * Compare the sample data between the source and destination sites.
+	 *
+	 * Builds a table of true/false values for each data point that is compared.
+	 *
+	 * @TODO This has gotten messy. See comments and refactor.
+	 *
+	 * @param array $source Data from the source site.
+	 * @param $destination
+	 *
+	 * @return array
+	 */
+	private function compare_sample( $source, $destination ) {
+		$comparison_output = array();
+
+		foreach ( $source as $index => $post_data ) {
+			$post = array();
+
+			foreach ( $post_data as $key => $data ) {
+				if ( 'ID' === $key ) {
+					$post['post_id'] = $data;
+
+					continue;
+				}
+
+				$post [ $key ] = $this->compare_sample_values( $key, $source[ $index ], $destination[ $index ] ) ? 'yes' : 'no';
+				$post[ $key ]  = $this->apply_diff_to_values( $source[ $index ][ $key ], $destination[ $index ][ $key ], $post[ $key ] );
+			}
+
+			$comparison_output[] = $post;
+		}
+
+		return $comparison_output;
+	}
+
+	/**
+	 * Compare values of metadata between source and destination sites.
+	 *
+	 * Source is treated as the truth. Destination must have all of the same meta keys with the same meta values for
+	 * this function to return true.
+	 *
+	 * @param string $key              Key to compare.
+	 * @param array  $source_data      Data from the local site.
+	 * @param array  $destination_data Data from the remote site.
+	 *
+	 * @return bool
+	 */
+	public function compare_sample_values( $key, $source_data, $destination_data ) {
+		if ( is_null( $destination_data ) ) {
+			return false;
+		}
+
+		// Non-meta comparisons are a simple equivalency check.
+		if ( 'meta' !== $key ) {
+			return $source_data[ $key ] === $destination_data[ $key ];
+		}
+
+		// Compare meta values.
+		$valid_meta = true;
+
+		foreach ( $source_data[ $key ] as $index => $value ) {
+			if ( ! isset( $destination_data[ $key ][ $index ] ) || $source_data[ $key ][ $index ] !== $destination_data[ $key ][ $index ] ) {
+				$valid_meta = false;
+
+				break;
+			}
+		}
+
+		return $valid_meta;
+	}
+
+	/**
+	 * Compare taxonomy data for individual posts.
+	 *
+	 * @param array $source      Source data.
+	 * @param array $destination Destination data.
+	 *
+	 * @return array
+	 */
+	public function compare_sample_tax( $source, $destination ) {
+		$data = array();
+
+		foreach ( $source as $post_id => $post_data ) {
+			foreach ( $post_data['terms'] as $taxonomy_key => $taxonomy ) {
+				$data[] = array(
+					'post_id'        => $post_id,
+					'taxonomy'       => $taxonomy_key,
+					'terms_migrated' => $this->apply_diff_to_values(
+						$source[ $post_id ]['terms'],
+						$destination[ $post_id ]['terms'],
+						$this->check_all_terms(
+							$source[ $post_id ]['terms'],
+							$destination[ $post_id ]['terms']
+						)
+					),
+				);
+			}
+		}
+
+		return $data;
+	}
+
+	/**
+	 * Compare source and destination terms for a particular post to confirm whether all terms have been migrated.
+	 *
+	 * @param array $source Source data.
+	 * @param array $destination Destination data.
+	 *
+	 * @return bool
+	 */
+	private function check_all_terms( $source, $destination ) {
+		if ( is_null( $destination ) ) {
+			return 'no';
+		}
+
+		if ( count( $source ) !== count( $destination ) ) {
+			return 'no';
+		}
+
+		foreach ( $source as $taxonomy_key => $taxonomy ) {
+			foreach ( $taxonomy as $term_key => $term ) {
+				if ( ! isset( $destination[ $taxonomy_key ][ $term_key ] )
+					|| $destination[ $taxonomy_key ][ $term_key ] !== $source[ $taxonomy_key ][ $term_key ] ) {
+					return 'no';
+				}
+			}
+		}
+
+		return 'yes';
+	}
+}
diff --git a/includes/validators/TaxonomyValidator.php b/includes/validators/TaxonomyValidator.php
new file mode 100644
index 0000000..0c59872
--- /dev/null
+++ b/includes/validators/TaxonomyValidator.php
@@ -0,0 +1,178 @@
+source_data      = $this->get_source_data();
+		$this->destination_data = $this->get_destination_data();
+
+		return array(
+			'source'      => $this->source_data,
+			'destination' => $this->destination_data,
+			'comparison'  => $this->get_comparison_data( $this->source_data, $this->destination_data ),
+		);
+	}
+
+	/**
+	 * Get taxonomy data from the local WordPress installation.
+	 *
+	 * @return array
+	 * @since NEXT
+	 */
+	public function get_source_data() {
+		return ( new Taxonomy() )->get_data();
+	}
+
+	/**
+	 * Get taxonomy data from the remote WordPress installation.
+	 *
+	 * @return array
+	 * @since NEXT
+	 */
+	public function get_destination_data() {
+		return array(
+			'count'      => API::get_remote_data( 'validation/taxonomy/count' ),
+			'post_terms' => API::get_remote_data( 'validation/taxonomy/post_terms' ),
+		);
+	}
+
+	/**
+	 * Compare source and destination data.
+	 *
+	 * @param array $source      Source data.
+	 * @param array $destination Destination data.
+	 *
+	 * @return array
+	 * @since NEXT
+	 */
+	public function get_comparison_data( array $source, array $destination ) {
+		return array(
+			'count'      => $this->compare_count( $source['count'], $destination['count'] ),
+			'post_terms' => $this->compare_post_terms( $source['post_terms'], $destination['post_terms'] ),
+		);
+	}
+
+	/**
+	 * @param $source
+	 * @param $destination
+	 *
+	 * @return array
+	 */
+	private function compare_count( $source, $destination ) {
+		$data = array();
+
+		foreach ( $source['term_count_by_taxonomy'] as $taxonomy_name => $count ) {
+			$data[ $taxonomy_name ] = array(
+				'taxonomy_name'     => $taxonomy_name,
+				'term_count'        => $count['number_of_terms'],
+				'destination_count' => 0,
+				'migrated'          => 'yes',
+			);
+		}
+
+		foreach ( $destination['term_count_by_taxonomy'] as $taxonomy_name => $count ) {
+			if ( ! isset( $data[ $taxonomy_name ] ) ) {
+				$data[ $taxonomy_name ] = array(
+					'taxonomy_name'     => $taxonomy_name,
+					'term_count'        => 0,
+					'destination_count' => $count['number_of_terms'],
+					'migrated'          => 'no',
+				);
+
+				continue;
+			}
+
+			$data[ $taxonomy_name ]['destination_count'] = $this->apply_diff_to_values( $count['number_of_terms'], $data[ $taxonomy_name ]['term_count'] );
+			$data[ $taxonomy_name ]['migrated']          = $this->apply_diff_to_values( $count['number_of_terms'], $data[ $taxonomy_name ]['term_count'], $data[ $taxonomy_name ]['migrated'] );
+		}
+
+		return $data;
+	}
+
+	/**
+	 * Build array of source and destination post count data.
+	 *
+	 * @param array $source Source data.
+	 * @param array $destination Destination data.
+	 *
+	 * @return array
+	 */
+	private function compare_post_terms( $source, $destination ) {
+		$data = array();
+
+		foreach ( $source as $key => $term_data ) {
+			$data[ $key ] = array(
+				'term'              => $term_data['slug'],
+				'taxonomy'          => $term_data['taxonomy'],
+				'count'             => $term_data['count'],
+				'destination_count' => 0,
+				'migrated'          => 'yes',
+			);
+		}
+
+		foreach ( $destination as $key => $term_data ) {
+			if ( ! isset( $data[ $key ] ) ) {
+				$data[ $key ] = array(
+					'term'              => $term_data['slug'],
+					'taxonomy'          => $term_data['taxonomy'],
+					'count'             => 0,
+					'destination_count' => $term_data['count'],
+				);
+
+				continue;
+			}
+
+			$data[ $key ]['destination_count'] = $destination[ $key ]['count'];
+		}
+
+		foreach ( $data as $key => $term ) {
+			$data[ $key ]['migrated'] = ( $data[ $key ]['count'] === $data[ $key ]['destination_count'] ) ? 'yes' : 'no';
+		}
+
+		$random_terms = $this->get_random_terms( $data );
+
+		foreach ( $random_terms as $key => $term ) {
+			$random_terms[ $key ]['migrated'] = $this->apply_diff_to_values( $term['count'], $term['destination_count'], $random_terms[ $key ]['migrated'] );
+			$random_terms[ $key ]['destination_count'] = $this->apply_diff_to_values( $term['count'], $term['destination_count'] );
+		}
+
+		return $random_terms;
+	}
+
+	/**
+	 * Reduce the full array set to a random set of terms for output.
+	 *
+	 * @param array $data Data to select random terms from.
+	 *
+	 * @return array
+	 */
+	private function get_random_terms( array $data ) {
+		$array_size      = count( $data );
+		$sample_size     = $array_size >= $this->args['sample_count'] ? $this->args['sample_count'] : $array_size;
+		$random_keys     = array_rand( $data, $sample_size );
+		$randomized_data = array_filter( $data, function ( $term ) use ( $random_keys ) {
+			$key = $term['term'] . '-' . $term['taxonomy'];
+			return in_array( $key, $random_keys, true );
+		} );
+
+		ksort( $randomized_data );
+
+		return $randomized_data;
+	}
+}
diff --git a/includes/validators/UserValidator.php b/includes/validators/UserValidator.php
new file mode 100644
index 0000000..f6ab6ab
--- /dev/null
+++ b/includes/validators/UserValidator.php
@@ -0,0 +1,119 @@
+ $this->get_source_data(),
+			'destination' => $this->get_destination_data(),
+			'comparison'  => array(),
+		);
+
+		$return['comparison'] = $this->get_comparison_data( $return['source'], $return['destination'] );
+		return $return;
+	}
+
+	/**
+	 * Get data from the source site.
+	 *
+	 * @since NEXT
+	 * @return array
+	 */
+	public function get_source_data() {
+		$this->source_data = ( new User( $this->args ) )->get_data();
+		return $this->source_data;
+	}
+
+	/**
+	 * Get data from the destination site.
+	 *
+	 * @since NEXT
+	 * @return array
+	 */
+	public function get_destination_data() {
+		return array(
+			'count'  => API::get_remote_data( 'validation/user/count' ),
+			'sample' => $this->get_destination_samples(),
+		);
+	}
+
+	/**
+	 * Get samples from the destination site based on source data.
+	 *
+	 * @since NEXT
+	 * @return array
+	 */
+	private function get_destination_samples() {
+		$args = array(
+			'source_users' => array(),
+		);
+
+		foreach ( $this->source_data['sample'] as $user ) {
+			$args['source_users'][] = array(
+				'ID'         => $user->ID,
+				'user_login' => $user->data->user_login,
+				'user_email' => $user->data->user_email,
+			);
+		}
+
+		return API::get_remote_data( 'validation/user/samples', $args );
+	}
+
+	/**
+	 * Compares source and destination data.
+	 *
+	 * @since NEXT
+	 * @param  array $source      The source dataset.
+	 * @param  array $destination The destination dataset.
+	 * @return array
+	 */
+	public function get_comparison_data( array $source, array $destination ) {
+		return array(
+			'count' => $this->compare_counts( $source['count'], $destination['count'] ),
+		);
+	}
+
+	/**
+	 * Compare counts between source and destination.
+	 *
+	 * @since NEXT
+	 * @param  array $source      Source counts.
+	 * @param  array $destination Destination counts.
+	 * @return array
+	 */
+	private function compare_counts( $source, $destination ) {
+		$comparison = array();
+
+		foreach ( $source as $role => $src_count ) {
+			$dest_count = 0;
+
+			if ( isset( $destination[ $role ] ) ) {
+				$dest_count = $destination[ $role ];
+			}
+
+			$comparison[ $role ] = $this->apply_diff_to_values( $src_count, $dest_count );
+		}
+
+		return $comparison;
+	}
+}
diff --git a/includes/validators/class-users.php b/includes/validators/class-users.php
new file mode 100644
index 0000000..fe1f548
--- /dev/null
+++ b/includes/validators/class-users.php
@@ -0,0 +1,88 @@
+local_data  = new LocalUserData();
+		$this->remote_data = new RemoteUserData();
+	}
+
+	/**
+	 * Get data from the source site.
+	 *
+	 * @since NEXT
+	 */
+	public function get_source_data() {
+		$this->source_data = array(
+			'counts'  => $this->local_data->get_count(),
+			'samples' => $this->local_data->get_samples(),
+		);
+	}
+
+	/**
+	 * Get data from the destination site.
+	 *
+	 * @since NEXT
+	 */
+	public function get_destination_data() {
+		$this->destination_data = array(
+			'counts'  => $this->remote_data->get_data( 'count' ),
+			'samples' => $this->remote_data->get_data( 'samples' ),
+		);
+	}
+
+	/**
+	 * Compare data from source and destination sites.
+	 *
+	 * @since NEXT
+	 * @return array
+	 */
+	public function validate() {
+		$this->get_source_data();
+		$this->get_destination_data();
+
+		$results = array(
+			'counts'  => array(),
+			'samples' => array(),
+		);
+
+		// Compare counts.
+		foreach ( $this->source_data['counts'] as $count_key => $count ) {
+			$results['counts'][ $count_key ] = $this->compare_counts( $count_key );
+		}
+
+		return $results;
+	}
+
+	/**
+	 * Method to compare counts.
+	 *
+	 * @since NEXT
+	 * @param  string $key The count key to compare.
+	 * @return string
+	 */
+	private function compare_counts( $key ) {
+		$source_count      = absint( $this->source_data['counts'][ $key ] );
+		$destination_count = absint( $this->destination_data['counts'][ $key ] );
+
+		$icon = $this->get_result_icon( $source_count === $destination_count );
+		return sprintf( '%s Count of %s: %d vs %d', $icon, $key, $source_count, $destination_count );
+	}
+}
diff --git a/views/dashboard/html-nav.php b/views/dashboard/html-nav.php
index d6798d0..bd2bbe0 100644
--- a/views/dashboard/html-nav.php
+++ b/views/dashboard/html-nav.php
@@ -9,6 +9,7 @@
 		
 		Bulk Sync
 		Credentials
+		Validation
 		Help
 		Advanced
 	
diff --git a/views/dashboard/html-validation.php b/views/dashboard/html-validation.php
new file mode 100644
index 0000000..3909f95
--- /dev/null
+++ b/views/dashboard/html-validation.php
@@ -0,0 +1,47 @@
+
+ include_page( 'dashboard/nav' ); ?> +
+
+

Validate Your Press Sync Data

+
+
+
+

Validation Tasks

+
+ + + + + $args ) : ?> + + + + + + + + + + + + + + +
+ + + +

Validation Results

+ +

+ There was a problem running the requested validation: getMessage() ); ?> +

+ +
+
+
+