diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 61d2369ff..9b90c29ef 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -50,3 +50,7 @@ Describe the actions you performed (e.g., commands you ran, text you typed, butt ### Applicable Issues + +### Changelog Entry + + diff --git a/.gitignore b/.gitignore index 8179ba32a..ddb7ff946 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ release /vendor/* phpunit.xml /dist +coverage debug.log diff --git a/README.md b/README.md index d622bb8d6..77f08ad65 100644 --- a/README.md +++ b/README.md @@ -20,13 +20,14 @@ * Classify your content using [IBM Watson's Natural Language Understanding API](https://www.ibm.com/watson/services/natural-language-understanding/) and [Microsoft Azure's Computer Vision API](https://azure.microsoft.com/en-us/services/cognitive-services/computer-vision/) * Supports Watson's [Categories](https://console.bluemix.net/docs/services/natural-language-understanding/index.html#categories), [Keywords](https://console.bluemix.net/docs/services/natural-language-understanding/index.html#keywords), [Concepts](https://console.bluemix.net/docs/services/natural-language-understanding/index.html#concepts) & [Entities](https://console.bluemix.net/docs/services/natural-language-understanding/index.html#entities) and Azure's [Describe Image](https://westus.dev.cognitive.microsoft.com/docs/services/5adf991815e1060e6355ad44/operations/56f91f2e778daf14a499e1fe) * Automatically classify content and images on save +* [Smartly crop images](https://docs.microsoft.com/en-us/rest/api/cognitiveservices/computervision/generatethumbnail) around a region of interest identified by Computer Vision * Bulk classify content with [WP-CLI](https://wp-cli.org/) ## Requirements * PHP 7.0+ * [WordPress](http://wordpress.org) 5.0+ -* To utilize the Lanaguage Processing functionality, you will need an active [IBM Watson](https://cloud.ibm.com/registration) account. +* To utilize the Language Processing functionality, you will need an active [IBM Watson](https://cloud.ibm.com/registration) account. * To utilize the Image Processing functionality, you will need an active [Microsoft Azure](https://signup.azure.com/signup) account. ## Installation @@ -61,13 +62,13 @@ #### 3. Configure Post Types to classify and IBM Watson Features to enable under ClassifAI > Language Processing - Choose which public post types to classify when saved. -- Chose whether to assign category, keyword, entity, and concept as well as the taxonomies used for each. +- Choose whether to assign category, keyword, entity, and concept as well as the taxonomies used for each. #### 4. Save Post or run WP CLI command to batch classify posts ## Set Up Image Processing (via Microsoft Azure) -Note that [Computer Vision](https://docs.microsoft.com/en-us/azure/cognitive-services/computer-vision/home#image-requirements) can analyze images that meet the following requirements: +Note that [Computer Vision](https://docs.microsoft.com/en-us/azure/cognitive-services/computer-vision/home#image-requirements) can analyze and crop images that meet the following requirements: - The image must be presented in JPEG, PNG, GIF, or BMP format - The file size of the image must be less than 4 megabytes (MB) - The dimensions of the image must be greater than 50 x 50 pixels diff --git a/includes/Classifai/Admin/Notifications.php b/includes/Classifai/Admin/Notifications.php index 171bde423..ba96333a7 100644 --- a/includes/Classifai/Admin/Notifications.php +++ b/includes/Classifai/Admin/Notifications.php @@ -50,7 +50,7 @@ public function maybe_render_notices() { if ( $needs_setup ) { printf( '

' . esc_html__( 'ClassifAI requires setup', 'classifai' ) . '

', - esc_url( admin_url( 'options-general.php?page=classifai_settings' ) ) + esc_url( admin_url( 'admin.php?page=classifai_settings' ) ) ); delete_transient( 'classifai_activation_notice' ); } diff --git a/includes/Classifai/Helpers.php b/includes/Classifai/Helpers.php index efba75063..93f4e3a09 100644 --- a/includes/Classifai/Helpers.php +++ b/includes/Classifai/Helpers.php @@ -302,3 +302,70 @@ function get_feature_taxonomy( $feature ) { */ return apply_filters( 'classifai_taxonomy_for_feature', $taxonomy, $feature ); } + +/** + * Provides the max filesize for the Computer Vision service. + * + * @since 1.4.0 + * + * @return int + */ +function computer_vision_max_filesize() { + /** + * Filters the Computer Vision maximum allowed filesize. + * + * @param int Default 4MB. + */ + return apply_filters( 'classifai_computer_vision_max_filesize', 4 * MB_IN_BYTES ); // 4MB default. +} + +/** + * Callback for sorting images by width plus height, descending. + * + * @since 1.5.0 + * + * @param array $size_1 Associative array containing width and height values. + * @param array $size_2 Associative array containing width and height values. + * @return int Returns -1 if $size_1 is larger, 1 if $size_2 is larger, and 0 if they are equal. + */ +function sort_images_by_size_cb( $size_1, $size_2 ) { + $size_1_total = $size_1['width'] + $size_1['height']; + $size_2_total = $size_2['width'] + $size_2['height']; + + if ( $size_1_total === $size_2_total ) { + return 0; + } + + return $size_1_total > $size_2_total ? -1 : 1; +} + +/** + * Retrieves the URL of the largest version of an attachment image lower than a specified max size. + * + * @since 1.4.0 + * + * @param string $full_image The path to the full-sized image source file. + * @param string $full_url The URL of the full-sized image. + * @param array $sizes Intermediate size data from attachment meta. + * @param int $max The maximum acceptable size. + * @return string|null The image URL, or null if no acceptable image found. + */ +function get_largest_acceptable_image_url( $full_image, $full_url, $sizes, $max = MB_IN_BYTES ) { + $file_size = @filesize( $full_image ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + if ( $file_size && $max >= $file_size ) { + return $full_url; + } + + usort( $sizes, __NAMESPACE__ . '\sort_images_by_size_cb' ); + + foreach ( $sizes as $size ) { + $sized_file = str_replace( basename( $full_image ), $size['file'], $full_image ); + $file_size = @filesize( $sized_file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + + if ( $file_size && $max >= $file_size ) { + return str_replace( basename( $full_url ), $size['file'], $full_url ); + } + } + + return null; +} diff --git a/includes/Classifai/Plugin.php b/includes/Classifai/Plugin.php index 00d9e0992..8e0e5021d 100644 --- a/includes/Classifai/Plugin.php +++ b/includes/Classifai/Plugin.php @@ -53,8 +53,9 @@ public function init() { $post_type, '_classifai_error', [ - 'show_in_rest' => true, - 'single' => true, + 'show_in_rest' => true, + 'single' => true, + 'auth_callback' => '__return_true', ] ); } diff --git a/includes/Classifai/Providers/Azure/ComputerVision.php b/includes/Classifai/Providers/Azure/ComputerVision.php index 4cfedc56a..b74dfee2d 100644 --- a/includes/Classifai/Providers/Azure/ComputerVision.php +++ b/includes/Classifai/Providers/Azure/ComputerVision.php @@ -7,6 +7,9 @@ use Classifai\Providers\Provider; +use function Classifai\computer_vision_max_filesize; +use function Classifai\get_largest_acceptable_image_url; + class ComputerVision extends Provider { /** @@ -48,6 +51,7 @@ public function can_register() { if ( empty( $options ) ) { return false; } + return true; } @@ -55,25 +59,52 @@ public function can_register() { * Register the functionality. */ public function register() { - add_filter( 'wp_generate_attachment_metadata', [ $this, 'process_image' ], 10, 2 ); + add_filter( 'wp_generate_attachment_metadata', [ $this, 'smart_crop_image' ], 8, 2 ); + add_filter( 'wp_generate_attachment_metadata', [ $this, 'generate_image_alt_tags' ], 8, 2 ); add_filter( 'posts_clauses', [ $this, 'filter_attachment_query_keywords' ], 10, 1 ); } - /** - * Provides the max filesize for the ComputerVision service. + * Adds smart-cropped image thumbnails to the attachment metadata. * - * @return int + * @since 1.5.0 + * @filter wp_generate_attachment_metadata * - * @since 1.4.0 + * @param array $metadata Attachment metadata. + * @param int $attachment_id Attachment ID. + * @return array Filtered attachment metadata. */ - public function get_max_filesize() { + public function smart_crop_image( $metadata, $attachment_id ) { + $settings = $this->get_settings(); + + if ( ! is_array( $metadata ) || ! is_array( $settings ) ) { + return $metadata; + } + + $should_smart_crop = isset( $settings['enable_smart_cropping'] ) && '1' === $settings['enable_smart_cropping']; + /** - * Filters the ComputerVision maximum allowed filesize. + * Filters whether to apply smart cropping to the current image. * - * @param int Default 4MB. + * @since 1.5.0 + * + * @param boolean Whether to apply smart cropping. The default value is set in ComputerVision settings. + * @param array Image metadata. + * @param int The attachment ID. */ - return apply_filters( 'classifai_computervision_max_filesize', 4 * MB_IN_BYTES ); // 4MB default. + if ( ! apply_filters( 'classifai_should_smart_crop_image', $should_smart_crop, $metadata, $attachment_id ) ) { + return $metadata; + } + + // Direct file system access is required for the current implementation of this feature. + $access_type = get_filesystem_method(); + if ( 'direct' !== $access_type || ! WP_Filesystem() ) { + return $metadata; + } + + $smart_cropping = new SmartCropping( $settings ); + + return $smart_cropping->generate_attachment_metadata( $metadata, intval( $attachment_id ) ); } /** @@ -84,7 +115,7 @@ public function get_max_filesize() { * * @return mixed */ - public function process_image( $metadata, $attachment_id ) { + public function generate_image_alt_tags( $metadata, $attachment_id ) { $settings = $this->get_settings(); if ( @@ -92,10 +123,11 @@ public function process_image( $metadata, $attachment_id ) { 'no' !== $settings['enable_image_captions'] ) { if ( isset( $metadata['sizes'] ) && is_array( $metadata['sizes'] ) ) { - $image_url = $this->get_largest_acceptable_image_url( + $image_url = get_largest_acceptable_image_url( get_attached_file( $attachment_id ), wp_get_attachment_url( $attachment_id, 'full' ), - $metadata['sizes'] + $metadata['sizes'], + computer_vision_max_filesize() ); } else { $image_url = wp_get_attachment_url( $attachment_id, 'full' ); @@ -121,48 +153,6 @@ public function process_image( $metadata, $attachment_id ) { return $metadata; } - /** - * Retrieves the URL of the largest version of an attachment image accepted by the ComputerVision service. - * - * @param string $full_image The path to the full-sized image source file. - * @param string $full_url The URL of the full-sized image. - * @param array $sizes Intermediate size data from attachment meta. - * @return string|null The image URL, or null if no acceptable image found. - * - * @since 1.4.0 - */ - public function get_largest_acceptable_image_url( $full_image, $full_url, $sizes ) { - $file_size = @filesize( $full_image ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged - if ( $file_size && $this->get_max_filesize() >= $file_size ) { - return $full_url; - } - - // Sort the image sizes in order of total width + height, descending. - $sort_sizes = function( $size_1, $size_2 ) { - $size_1_total = $size_1['width'] + $size_1['height']; - $size_2_total = $size_2['width'] + $size_2['height']; - - if ( $size_1_total === $size_2_total ) { - return 0; - } - - return $size_1_total > $size_2_total ? -1 : 1; - }; - - usort( $sizes, $sort_sizes ); - - foreach ( $sizes as $size ) { - $sized_file = str_replace( basename( $full_image ), $size['file'], $full_image ); - $file_size = @filesize( $sized_file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged - - if ( $file_size && $this->get_max_filesize() >= $file_size ) { - return str_replace( basename( $full_url ), $size['file'], $full_url ); - } - } - - return null; - } - /** * Scan the image and return the captions. * @@ -396,6 +386,23 @@ public function setup_fields_sections() { 'options' => $options, ] ); + + add_settings_field( + 'enable-smart-cropping', + esc_html__( 'Enable smart cropping', 'classifai' ), + [ $this, 'render_input' ], + $this->get_option_name(), + $this->get_option_name(), + [ + 'label_for' => 'enable_smart_cropping', + 'input_type' => 'checkbox', + 'default_value' => false, + 'description' => __( + 'Crop images around a region of interest identified by ComputerVision', + 'classifai' + ), + ] + ); } /** @@ -457,6 +464,9 @@ public function sanitize_settings( $settings ) { $new_settings['image_tag_taxonomy'] = $settings['image_tag_taxonomy']; } + $smart_cropping_enabled = isset( $settings['enable_smart_cropping'] ) ? '1' : 'no'; + $new_settings['enable_smart_cropping'] = $smart_cropping_enabled; + return $new_settings; } @@ -497,6 +507,7 @@ protected function authenticate_credentials( $url, $api_key ) { * Provides debug information related to the provider. * * @param null|array $settings Settings array. If empty, settings will be retrieved. + * @return array Keyed array of debug information. * @since 1.4.0 */ public function get_provider_debug_information( $settings = null ) { diff --git a/includes/Classifai/Providers/Azure/SmartCropping.php b/includes/Classifai/Providers/Azure/SmartCropping.php new file mode 100644 index 000000000..6f23a7f61 --- /dev/null +++ b/includes/Classifai/Providers/Azure/SmartCropping.php @@ -0,0 +1,338 @@ +settings = $settings; + } + + /** + * Provides the global WP_Filesystem_Base class instance. + * + * @since 1.5.0 + * + * @return WP_Filesystem_Base + */ + public function get_wp_filesystem() { + global $wp_filesystem; + + if ( is_null( $this->wp_filesystem ) ) { + if ( ! $wp_filesystem ) { + WP_Filesystem(); // Initiates the global. + } + + $this->wp_filesystem = $wp_filesystem; + } + + /** + * Filters the filesystem class instance used to save image files. + * + * @since 1.5.0 + * + * @param WP_Filesystem_Base + */ + return apply_filters( 'classifai_smart_crop_wp_filesystem', $this->wp_filesystem ); + } + + /** + * Provides the maximum allowable width or height in pixels accepted by the generateThumbnail endpoint. + * + * @since 1.5.0 + * @see https://docs.microsoft.com/en-us/rest/api/cognitiveservices/computervision/generatethumbnail/generatethumbnail#uri-parameters + * + * @return int + */ + public function get_max_pixel_dimension() { + /** + * Filters the maximum allowable width or height of an image to be cropped. Default 1024. + * + * @param int The width/height in pixels. + */ + return apply_filters( 'classifai_smart_crop_max_pixel_dimension', 1024 ); + } + + /** + * Returns whether smart cropping should be applied to images of a given size. + * + * @since 1.5.0 + * + * @param string $size An image size. + * @return boolean + */ + public function should_crop( $size ) { + if ( 'thumbnail' === $size ) { + return boolval( get_option( 'thumbnail_crop', false ) ); + } + + $image_sizes = wp_get_additional_image_sizes(); + if ( ! isset( $image_sizes[ $size ] ) + || ! isset( $image_sizes[ $size ]['height'] ) + || ! isset( $image_sizes[ $size ]['width'] ) ) { + return false; + } + + // If positions are specified in the add_image_size crop argument, as indicated by the crop field being an + // array, then that should take priority and smart cropping should not run. + if ( is_array( $image_sizes[ $size ]['crop'] ) ) { + $return = false; + } else { + $return = boolval( $image_sizes[ $size ]['crop'] ); + } + + $max_pixels = $this->get_max_pixel_dimension(); + if ( $max_pixels < $image_sizes[ $size ]['height'] || $max_pixels < $image_sizes[ $size ]['width'] ) { + $return = false; + } + + /** + * Filters whether to smart crop images of a given size. + * + * @since 1.5.0 + * + * @param boolean Whether non-position-based cropping was opted into when registering the image size. + * @param string The image size. + */ + return apply_filters( 'classifai_should_crop_size', $return, $size ); + } + + /** + * Filters attachment meta data + * + * @since 1.5.0 + * + * @param array $metadata Image attachment metadata. + * @param int $attachment_id Attachment ID. + * @return array Filtered image attachment metadata. + */ + public function generate_attachment_metadata( $metadata, $attachment_id ) { + if ( ! isset( $metadata['sizes'] ) || empty( $metadata['sizes'] ) ) { + return $metadata; + } + + foreach ( $metadata['sizes'] as $size => $size_data ) { + if ( ! $this->should_crop( $size ) ) { + continue; + } + + $data = [ + 'width' => $size_data['width'], + 'height' => $size_data['height'], + ]; + + $better_thumb_filename = $this->get_cropped_thumbnail( $attachment_id, $data ); + if ( ! empty( $better_thumb_filename ) ) { + $metadata['sizes'][ $size ]['file'] = basename( $better_thumb_filename ); + } + } + + return $metadata; + } + + /** + * Gets a cropped thumbnail from the Azure API. + * + * @since 1.5.0. + * + * @param int $attachment_id Attachment ID. + * @param array $size_data Attachment metadata size data. + * @return bool|mixed The thumbnail file name or false on failure. + */ + public function get_cropped_thumbnail( $attachment_id, $size_data ) { + /** + * Filters the image URL to send to Computer Vision for smart cropping. A non-null value will override default + * plugin behavior. + * + * @since 1.5.0 + * + * @param null|string Null to use default plugin behavior; string to override. + * @param int The attachment image ID. + */ + $url = apply_filters( 'classifai_smart_cropping_source_url', null, $attachment_id ); + + if ( empty( $url ) ) { + $url = get_largest_acceptable_image_url( + get_attached_file( $attachment_id ), + wp_get_attachment_url( $attachment_id, 'full' ), + $size_data, + computer_vision_max_filesize() + ); + } + + if ( empty( $url ) || empty( $size_data ) || ! is_array( $size_data ) ) { + return false; + } + + $data = [ + 'width' => $size_data['width'], + 'height' => $size_data['height'], + 'url' => $url, + ]; + + $new_thumb_image = $this->request_cropped_thumbnail( $data ); + if ( empty( $new_thumb_image ) ) { + return false; + } + + $attached_file = get_attached_file( $attachment_id ); + $file_path_info = pathinfo( $attached_file ); + $new_thumb_file_name = str_replace( + $file_path_info['filename'], + sprintf( + '%s-%dx%d', + $file_path_info['filename'], + $size_data['width'], + $size_data['height'] + ), + $attached_file + ); + + /** + * Filters the file name of the smart-cropped image. By default, the filename mirrors what is generated by + * core -- e.g., my-thumb-150x150.jpg -- so will override the core-generated image. Apply this filter to keep + * the original file in the file system. + * + * @since 1.5.0 + * + * @param string Default file name. + * @param int The ID of the attachment being processed. + * @param array Width and height data for the image. + */ + $new_thumb_file_name = apply_filters( + 'classifai_smart_cropping_thumb_file_name', + $new_thumb_file_name, + $attachment_id, + $size_data + ); + + $filesystem = $this->get_wp_filesystem(); + if ( $filesystem && $filesystem->put_contents( $new_thumb_file_name, $new_thumb_image ) ) { + return $new_thumb_file_name; + } + + return false; + } + + /** + * Builds the API url. + * + * @since 1.5.0 + * + * @return string + */ + public function get_api_url() { + return sprintf( '%s%s', trailingslashit( $this->settings['url'] ), static::API_PATH ); + } + + /** + * Fetch thumbnail using Azure API. + * + * @since 1.5.0 + * + * @param array $data Data for an attachment image size. + * @return bool|string + */ + public function request_cropped_thumbnail( $data ) { + $url = add_query_arg( + [ + 'height' => $data['height'], + 'width' => $data['width'], + 'smartCropping' => true, + ], + $this->get_api_url() + ); + + $response = wp_remote_post( + $url, + [ + 'body' => wp_json_encode( + [ + 'url' => $data['url'], + ] + ), + 'headers' => [ + 'Content-Type' => 'application/json', + 'Ocp-Apim-Subscription-Key' => $this->settings['api_key'], + ], + ] + ); + + /** + * Fires after the request to the generateThumbnail smart-cropping endpoint has run. + * + * @since 1.5.0 + * + * @param array|WP_Error Response data or a WP_Error if the request failed. + * @param string The request URL with query args added. + * @param array Array containing the image height and width. + */ + do_action( 'classifai_smart_cropping_after_request', $response, $url, $data ); + + if ( 200 === wp_remote_retrieve_response_code( $response ) ) { + return wp_remote_retrieve_body( $response ); + } + + /** + * Fires when the generateThumbnail smart-cropping API response did not have a 200 status code. + * + * @since 1.5.0 + * + * @param array|WP_Error Response data or a WP_Error if the request failed. + * @param string The request URL with query args added. + * @param array Array containing the image height and width. + */ + do_action( 'classifai_smart_cropping_unsuccessful_response', $response, $url, $data ); + + return false; + } +} diff --git a/includes/Classifai/Providers/Watson/NLU.php b/includes/Classifai/Providers/Watson/NLU.php index ebb55cb66..110f08e49 100644 --- a/includes/Classifai/Providers/Watson/NLU.php +++ b/includes/Classifai/Providers/Watson/NLU.php @@ -168,7 +168,7 @@ public function get_settings( $index = false ) { public function enqueue_editor_assets() { wp_enqueue_script( 'classifai-editor', // Handle. - CLASSIFAI_PLUGIN_URL . '/dist/js/editor.min.js', + CLASSIFAI_PLUGIN_URL . 'dist/js/editor.min.js', array( 'wp-blocks', 'wp-i18n', 'wp-element', 'wp-editor', 'wp-edit-post' ), CLASSIFAI_PLUGIN_VERSION, true diff --git a/tests/Classifai/HelpersTest.php b/tests/Classifai/HelpersTest.php index f18609578..cbc5076dc 100644 --- a/tests/Classifai/HelpersTest.php +++ b/tests/Classifai/HelpersTest.php @@ -6,6 +6,16 @@ class HelpersTest extends \WP_UnitTestCase { function setUp() { parent::setUp(); + $this->remove_added_uploads(); + } + + /** + * Tear down method. + */ + public function tearDown() { + parent::tearDown(); + + $this->remove_added_uploads(); } function test_it_has_a_plugin_instance() { @@ -135,4 +145,97 @@ function test_it_knows_configured_feature_taxonomies() { $this->assertEquals( $taxonomy, $actual ); } } + + /** + * @covers \Classifai\sort_images_by_size_cb + */ + public function test_sort_images_by_size_cb() { + $this->assertEquals( + 0, + sort_images_by_size_cb( + [ + 'height' => 4, + 'width' => 6, + ], + [ + 'height' => 2, + 'width' => 8, + ] + ) + ); + + $this->assertEquals( + -1, + sort_images_by_size_cb( + [ + 'height' => 4, + 'width' => 7, + ], + [ + 'height' => 2, + 'width' => 8, + ] + ) + ); + + $this->assertEquals( + 1, + sort_images_by_size_cb( + [ + 'height' => 4, + 'width' => 6, + ], + [ + 'height' => 2, + 'width' => 9, + ] + ) + ); + } + + /** + * @covers \Classifai\get_largest_acceptable_image_url + */ + public function test_get_largest_acceptable_image_url() { + $attachment = $this->factory->attachment->create_upload_object( DIR_TESTDATA .'/images/33772.jpg' ); // ~172KB image. + + $set_150kb_max_filesize = function() { + return 150000; + }; + add_filter( 'classifai_computer_vision_max_filesize', $set_150kb_max_filesize ); + + $url = get_largest_acceptable_image_url( + get_attached_file( $attachment ), + wp_get_attachment_url( $attachment, 'full' ), + wp_get_attachment_metadata( $attachment )['sizes'], + computer_vision_max_filesize() + ); + $this->assertEquals( sprintf( 'http://example.org/wp-content/uploads/%s/%s/33772-1536x864.jpg', date( 'Y' ), date( 'm' ) ), $url ); + + $attachment = $this->factory->attachment->create_upload_object( DIR_TESTDATA .'/images/2004-07-22-DSC_0008.jpg' ); // ~109kb image. + $url = get_largest_acceptable_image_url( + get_attached_file( $attachment ), + wp_get_attachment_url( $attachment, 'full' ), + wp_get_attachment_metadata( $attachment )['sizes'], + computer_vision_max_filesize() + ); + $this->assertEquals( sprintf( 'http://example.org/wp-content/uploads/%s/%s/2004-07-22-DSC_0008.jpg', date( 'Y' ), date( 'm' ) ), $url ); + + remove_filter( 'classifai_computer_vision_max_filesize', $set_150kb_max_filesize ); + + $set_1kb_max_filesize = function() { + return 1000; + }; + add_filter( 'classifai_computer_vision_max_filesize', $set_1kb_max_filesize ); + + $url = get_largest_acceptable_image_url( + get_attached_file( $attachment ), + wp_get_attachment_url( $attachment, 'full' ), + wp_get_attachment_metadata( $attachment )['sizes'], + computer_vision_max_filesize() + ); + $this->assertNull( $url ); + + remove_filter( 'classifai_computer_vision_max_filesize', $set_1kb_max_filesize ); + } } diff --git a/tests/Classifai/Providers/Azure/ComputerVisionTest.php b/tests/Classifai/Providers/Azure/ComputerVisionTest.php index f5ebdd4ab..be7f98354 100644 --- a/tests/Classifai/Providers/Azure/ComputerVisionTest.php +++ b/tests/Classifai/Providers/Azure/ComputerVisionTest.php @@ -13,19 +13,12 @@ * @package Classifai\Tests\Providers\Azure; * * @group azure + * @coversDefaultClass \Classifai\Providers\Azure\ComputerVision */ class ComputerVisionTest extends WP_UnitTestCase { - protected $computer_vision; - /** - * Setup method. + * Tear down method. */ - public function setUp() { - parent::setUp(); - - $this->computer_vision = new ComputerVision( 'my_service' ); - } - public function tearDown() { parent::tearDown(); @@ -33,45 +26,57 @@ public function tearDown() { } /** - * Tests the get_largest_acceptable_image_url method. + * Provides a ComputerVision instance. + * + * @return ComputerVision */ - public function test_get_largest_acceptable_image_url() { - $attachment = $this->factory->attachment->create_upload_object( DIR_TESTDATA .'/images/33772.jpg' ); // ~172KB image. - - $set_150kb_max_filesize = function() { - return 150000; - }; - add_filter( 'classifai_computervision_max_filesize', $set_150kb_max_filesize ); + public function get_computer_vision() : ComputerVision { + return new ComputerVision( 'my_service' ); + } - $url = $this->computer_vision->get_largest_acceptable_image_url( - get_attached_file( $attachment ), - wp_get_attachment_url( $attachment, 'full' ), - wp_get_attachment_metadata( $attachment )['sizes'] + /** + * @covers ::smart_crop_image + */ + public function test_smart_crop_image() { + $this->assertEquals( + 'non-array-data', + $this->get_computer_vision()->smart_crop_image( 'non-array-data', 999999 ) ); - $this->assertEquals( sprintf( 'http://example.org/wp-content/uploads/%s/%s/33772-1536x864.jpg', date( 'Y' ), date( 'm' ) ), $url ); - $attachment = $this->factory->attachment->create_upload_object( DIR_TESTDATA .'/images/2004-07-22-DSC_0008.jpg' ); // ~109kb image. - $url = $this->computer_vision->get_largest_acceptable_image_url( - get_attached_file( $attachment ), - wp_get_attachment_url( $attachment, 'full' ), - wp_get_attachment_metadata( $attachment )['sizes'] + $this->assertEquals( + [ 'no-smart-cropping' => 1 ], + $this->get_computer_vision()->smart_crop_image( + [ 'no-smart-cropping' => 1 ], + 999999 + ) ); - $this->assertEquals( sprintf( 'http://example.org/wp-content/uploads/%s/%s/2004-07-22-DSC_0008.jpg', date( 'Y' ), date( 'm' ) ), $url ); - remove_filter( 'classifai_computervision_max_filesize', $set_150kb_max_filesize ); + add_filter( 'classifai_should_smart_crop_image', '__return_true' ); - $set_1kb_max_filesize = function() { - return 1000; + $filter_file_system_method = function() { + return 'not-direct'; }; - add_filter( 'classifai_computervision_max_filesize', $set_1kb_max_filesize ); - $url = $this->computer_vision->get_largest_acceptable_image_url( - get_attached_file( $attachment ), - wp_get_attachment_url( $attachment, 'full' ), - wp_get_attachment_metadata( $attachment )['sizes'] + add_filter( 'filesystem_method', $filter_file_system_method ); + $this->assertEquals( + [ 'not-direct-file-system-method' => 1 ], + $this->get_computer_vision()->smart_crop_image( + [ 'not-direct-file-system-method' => 1 ], + 999999 + ) + ); + remove_filter( 'filesystem_method', $filter_file_system_method ); + + // Test that SmartCropping is initiated and runs, as will be indicated in the coverage report, though it won't + // actually do anything because the data and attachment are invalid. + $this->assertEquals( + [ 'my-data' => 1 ], + $this->get_computer_vision()->smart_crop_image( + [ 'my-data' => 1 ], + 999999 + ) ); - $this->assertNull( $url ); - remove_filter( 'classifai_computervision_max_filesize', $set_1kb_max_filesize ); + remove_filter( 'classifai_should_smart_crop_image', '__return_true' ); } } diff --git a/tests/Classifai/Providers/Azure/SmartCroppingTest.php b/tests/Classifai/Providers/Azure/SmartCroppingTest.php new file mode 100644 index 000000000..31505f123 --- /dev/null +++ b/tests/Classifai/Providers/Azure/SmartCroppingTest.php @@ -0,0 +1,225 @@ +remove_added_uploads(); + } + + /** + * Provides a SmartCropping instance for testing. + * + * @param array $args Args to pass to the SmartCropping constructor. + * @return SmartCropping + */ + public function get_smart_cropping( + array $args = [ 'url' => 'my-api-url.com', 'api_key' => 'my-key' ] + ) : SmartCropping { + return new SmartCropping( $args ); + } + + /** + * Runs a callback with a filter overriding the smart cropping API request. + * + * @param callable $callback The function to run with the filter. + */ + public function with_http_request_filter( callable $callback ) { + $filter = function( $response, array $parsed_args, string $url ) : array { + $response = [ + 'body' => file_get_contents( DIR_TESTDATA .'/images/33772.jpg' ), + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ] + ]; + + if ( false !== strpos( $url, 'my-bad-url.com' ) ) { + $response['response']['code'] = 400; + } + + return $response; + }; + add_filter( 'pre_http_request', $filter, 10, 3 ); + + $callback(); + + remove_filter( 'pre_http_request', $filter ); + } + + /** + * @covers ::__construct + * @covers ::get_wp_filesystem + */ + public function test_get_wp_filesystem() { + $this->assertInstanceOf( + WP_Filesystem_Direct::class, + $this->get_smart_cropping()->get_wp_filesystem() + ); + } + + /** + * @covers ::should_crop + */ + public function test_should_crop() { + global $_wp_additional_image_sizes; + $saved_additonal_image_sizes = $_wp_additional_image_sizes;; + + add_image_size( 'test-cropped-image-size', 600, 500, true ); + add_image_size( 'test-position-cropped-image-size', 600, 400, [ 'right', 'bottom' ] ); + + $smart_cropping = $this->get_smart_cropping(); + + $this->assertTrue( $smart_cropping->should_crop( 'thumbnail' ) ); + $this->assertFalse( $smart_cropping->should_crop( 'nonexistent-size' ) ); + $this->assertTrue( $smart_cropping->should_crop( 'test-cropped-image-size' ) ); + $this->assertFalse( $smart_cropping->should_crop( 'test-position-cropped-image-size' ) ); + + // Reset. + $_wp_additional_image_sizes = $saved_additonal_image_sizes; + } + + /** + * @covers ::generate_attachment_metadata + */ + public function test_generate_attachment_metadata() { + $attachment = $this->factory->attachment->create_upload_object( DIR_TESTDATA .'/images/33772.jpg' ); + + // Test that nothing happens when the metadata contains no sizes entry. + $this->assertEquals( + [ 'no-sizes' => 1 ], + $this->get_smart_cropping()->generate_attachment_metadata( + [ 'no-sizes' => 1 ], + $attachment + ) + ); + + $with_filter_cb = function() use ( $attachment ) { + $filtered_data = $this->get_smart_cropping()->generate_attachment_metadata( + wp_get_attachment_metadata( $attachment ), + $attachment + ); + + $this->assertEquals( + '33772-150x150.jpg', + $filtered_data['sizes']['thumbnail']['file'] + ); + }; + + $this->with_http_request_filter( $with_filter_cb ); + } + + /** + * @covers ::get_cropped_thumbnail + */ + public function test_get_cropped_thumbnail() { + // Test invalid data returns false. + $this->assertFalse( $this->get_smart_cropping()->get_cropped_thumbnail( 999999999, [] ) ); + + $attachment = $this->factory->attachment->create_upload_object( DIR_TESTDATA .'/images/33772.jpg' ); + + // Test bad request returns false. + $this->assertFalse( + $this->get_smart_cropping( + [ + 'url' => 'my-bad-url.com', + 'api_key' => 'my-key', + ] + )->get_cropped_thumbnail( + $attachment, + wp_get_attachment_metadata( $attachment )['sizes']['thumbnail'] + ) + ); + + $with_filter_cb = function() use ( $attachment ) { + $this->assertEquals( + sprintf( '/tmp/wordpress/wp-content/uploads/%s/%s/33772-150x150.jpg', date( 'Y' ), date( 'm' ) ), + $this->get_smart_cropping()->get_cropped_thumbnail( + $attachment, + wp_get_attachment_metadata( $attachment )['sizes']['thumbnail'] + ) + ); + + // Test when file operations fail. + add_filter( 'classifai_smart_crop_wp_filesystem', '__return_false' ); + $this->assertFalse( + $this->get_smart_cropping()->get_cropped_thumbnail( + $attachment, + wp_get_attachment_metadata( $attachment )['sizes']['thumbnail'] + ) + ); + remove_filter( 'classifai_smart_crop_wp_filesystem', '__return_false' ); + }; + + $this->with_http_request_filter( $with_filter_cb ); + } + + /** + * @covers ::get_api_url + */ + public function test_get_api_url() { + $this->assertEquals( + 'my-api-url.com/vision/v2.0/generateThumbnail/', + $this->get_smart_cropping()->get_api_url() + ); + } + + /** + * @covers ::request_cropped_thumbnail + */ + public function test_request_cropped_thumbnail() { + $with_filter_cb = function() { + // Test successful request. + $this->assertEquals( + file_get_contents( DIR_TESTDATA .'/images/33772.jpg' ), + $this->get_smart_cropping()->request_cropped_thumbnail( + [ + 'height' => 100, + 'width' => 100, + 'url' => 'my-image-url.jpeg', + ] + ) + ); + + // Test failed request. + $this->assertFalse( + $this->get_smart_cropping( + [ + 'url' => 'my-bad-url.com', + 'api_key' => 'my-key', + ] + )->request_cropped_thumbnail( + [ + 'height' => 100, + 'width' => 100, + 'url' => 'my-image-url.jpeg', + ] + ) + ); + }; + + $this->with_http_request_filter( $with_filter_cb ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 9991e5a50..d896c3581 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -6,6 +6,7 @@ $_tests_dir = getenv( 'WP_TESTS_DIR' ); +define( 'FS_METHOD', 'direct' ); define( 'TEST_DIR', dirname( __FILE__ ) ); define( 'PHPUNIT_RUNNER', true );