From 797627ee52b36a71ac40a6076a0d2a8fd3a7525e Mon Sep 17 00:00:00 2001 From: Ramon Date: Thu, 25 Sep 2025 15:08:06 +1000 Subject: [PATCH 1/8] Enhancement: Add flip capability and editable fields for newly-created attachments in the REST API. This update introduces the ability to flip images both vertically and horizontally through the REST API. Additionally, it allows for the editing of attachment fields such as title, caption, description, and alt text when creating new attachments. Fixes #64035. --- .../class-wp-rest-attachments-controller.php | 189 ++++++++++++++---- .../rest-api/rest-attachments-controller.php | 114 +++++++++++ 2 files changed, 261 insertions(+), 42 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index 53b94bf43947b..a45f05f572fb7 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -543,6 +543,7 @@ public function edit_media_item_permissions_check( $request ) { * Applies edits to a media item and creates a new attachment record. * * @since 5.5.0 + * @since 6.9.0 Adds flips capability and editable fields for the newly-created attachment post. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure. @@ -563,7 +564,7 @@ public function edit_media_item( $request ) { ) { return new WP_Error( 'rest_unknown_attachment', - __( 'Unable to get meta information for file.' ), + __( 'Unable to get meta information for file.', 'gutenberg' ), array( 'status' => 404 ) ); } @@ -573,7 +574,7 @@ public function edit_media_item( $request ) { if ( ! in_array( $mime_type, $supported_types, true ) ) { return new WP_Error( 'rest_cannot_edit_file_type', - __( 'This type of file cannot be edited.' ), + __( 'This type of file cannot be edited.', 'gutenberg' ), array( 'status' => 400 ) ); } @@ -584,6 +585,20 @@ public function edit_media_item( $request ) { } else { $modifiers = array(); + if ( isset( $request['flip']['horizontal'] ) || isset( $request['flip']['vertical'] ) ) { + $flip_args = array( + 'vertical' => $request['flip']['vertical'] ?? 0, + 'horizontal' => $request['flip']['horizontal'] ?? 0, + ); + + $modifiers[] = array( + 'type' => 'flip', + 'args' => array( + 'flip' => $flip_args, + ), + ); + } + if ( ! empty( $request['rotation'] ) ) { $modifiers[] = array( 'type' => 'rotate', @@ -608,7 +623,7 @@ public function edit_media_item( $request ) { if ( 0 === count( $modifiers ) ) { return new WP_Error( 'rest_image_not_edited', - __( 'The image was not edited. Edit the image before applying the changes.' ), + __( 'The image was not edited. Edit the image before applying the changes.', 'gutenberg' ), array( 'status' => 400 ) ); } @@ -629,7 +644,7 @@ public function edit_media_item( $request ) { if ( is_wp_error( $image_editor ) ) { return new WP_Error( 'rest_unknown_image_file_type', - __( 'Unable to edit this image.' ), + __( 'Unable to edit this image.', 'gutenberg' ), array( 'status' => 500 ) ); } @@ -637,6 +652,21 @@ public function edit_media_item( $request ) { foreach ( $modifiers as $modifier ) { $args = $modifier['args']; switch ( $modifier['type'] ) { + case 'flip': + /* + * Flips the current image. + * The vertical flip is the first argument (flip along horizontal axis), the horizontal flip is the second argument (flip along vertical axis). + * See: WP_Image_Editor::flip() + */ + $result = $image_editor->flip( 0 !== (int) $args['flip']['vertical'], 0 !== (int) $args['flip']['horizontal'] ); + if ( is_wp_error( $result ) ) { + return new WP_Error( + 'rest_image_flip_failed', + __( 'Unable to flip this image.', 'gutenberg' ), + array( 'status' => 500 ) + ); + } + break; case 'rotate': // Rotation direction: clockwise vs. counterclockwise. $rotate = 0 - $args['angle']; @@ -647,7 +677,7 @@ public function edit_media_item( $request ) { if ( is_wp_error( $result ) ) { return new WP_Error( 'rest_image_rotation_failed', - __( 'Unable to rotate this image.' ), + __( 'Unable to rotate this image.', 'gutenberg' ), array( 'status' => 500 ) ); } @@ -669,7 +699,7 @@ public function edit_media_item( $request ) { if ( is_wp_error( $result ) ) { return new WP_Error( 'rest_image_crop_failed', - __( 'Unable to crop this image.' ), + __( 'Unable to crop this image.', 'gutenberg' ), array( 'status' => 500 ) ); } @@ -711,23 +741,30 @@ public function edit_media_item( $request ) { return $saved; } - // Create new attachment post. - $new_attachment_post = array( - 'post_mime_type' => $saved['mime-type'], - 'guid' => $uploads['url'] . "/$filename", - 'post_title' => $image_name, - 'post_content' => '', - ); + // Grab original attachment post so we can use it to set defaults. + $original_attachment_post = get_post( $attachment_id ); - // Copy post_content, post_excerpt, and post_title from the edited image's attachment post. - $attachment_post = get_post( $attachment_id ); + // Check request fields and assign default values. + $new_attachment_post = $this->prepare_item_for_database( $request ); + $new_attachment_post->post_mime_type = $saved['mime-type']; + $new_attachment_post->guid = $uploads['url'] . "/$filename"; - if ( $attachment_post ) { - $new_attachment_post['post_content'] = $attachment_post->post_content; - $new_attachment_post['post_excerpt'] = $attachment_post->post_excerpt; - $new_attachment_post['post_title'] = $attachment_post->post_title; - } + // Unset ID so wp_insert_attachment generates a new ID. + unset( $new_attachment_post->ID ); + // Set new attachment post title with fallbacks. + $new_attachment_post->post_title = $new_attachment_post->post_title ?? $original_attachment_post->post_title ?? $image_name; + + // Set new attachment post caption (post_excerpt). + $new_attachment_post->post_excerpt = $new_attachment_post->post_excerpt ?? $original_attachment_post->post_excerpt ?? ''; + + // Set new attachment post description (post_content) with fallbacks. + $new_attachment_post->post_content = $new_attachment_post->post_content ?? $original_attachment_post->post_content ?? ''; + + // Set post parent if set in request, else the default of `0` (no parent). + $new_attachment_post->post_parent = $new_attachment_post->post_parent ?? 0; + + // Insert the new attachment post. $new_attachment_id = wp_insert_attachment( wp_slash( $new_attachment_post ), $saved['path'], 0, true ); if ( is_wp_error( $new_attachment_id ) ) { @@ -740,8 +777,8 @@ public function edit_media_item( $request ) { return $new_attachment_id; } - // Copy the image alt text from the edited image. - $image_alt = get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ); + // First, try to use the alt text from the request. If not set, copy the image alt text from the original attachment. + $image_alt = isset( $request['alt_text'] ) ? sanitize_text_field( $request['alt_text'] ) : get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ); if ( ! empty( $image_alt ) ) { // update_post_meta() expects slashed. @@ -790,6 +827,7 @@ public function edit_media_item( $request ) { * @param int $new_attachment_id Attachment post ID for the new image. * @param int $attachment_id Attachment post ID for the edited (parent) image. */ + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound $new_image_meta = apply_filters( 'wp_edited_image_metadata', $new_image_meta, $new_attachment_id, $attachment_id ); wp_update_attachment_metadata( $new_attachment_id, $new_image_meta ); @@ -1480,23 +1518,25 @@ protected function check_upload_size( $file ) { * Gets the request args for the edit item route. * * @since 5.5.0 + * @since 6.9.0 Adds flips capability and editable fields for the newly-created attachment post. * * @return array */ protected function get_edit_media_item_args() { - return array( + $args = array( 'src' => array( - 'description' => __( 'URL to the edited image file.' ), + 'description' => __( 'URL to the edited image file.', 'gutenberg' ), 'type' => 'string', 'format' => 'uri', 'required' => true, ), + // The `modifiers` param takes precedence over the older format. 'modifiers' => array( - 'description' => __( 'Array of image edits.' ), + 'description' => __( 'Array of image edits.', 'gutenberg' ), 'type' => 'array', 'minItems' => 1, 'items' => array( - 'description' => __( 'Image edit.' ), + 'description' => __( 'Image edit.', 'gutenberg' ), 'type' => 'object', 'required' => array( 'type', @@ -1504,22 +1544,59 @@ protected function get_edit_media_item_args() { ), 'oneOf' => array( array( - 'title' => __( 'Rotation' ), + 'title' => __( 'Flip', 'gutenberg' ), + 'properties' => array( + 'type' => array( + 'description' => __( 'Flip type.', 'gutenberg' ), + 'type' => 'string', + 'enum' => array( 'flip' ), + ), + 'args' => array( + 'description' => __( 'Flip arguments.', 'gutenberg' ), + 'type' => 'object', + 'required' => array( + 'flip', + ), + 'properties' => array( + 'flip' => array( + 'description' => __( 'Flip direction. [ horizontal, vertical ] 0 for no flip, 1 for flip.', 'gutenberg' ), + 'type' => 'object', + 'required' => array( + 'horizontal', + 'vertical', + ), + 'properties' => array( + 'horizontal' => array( + 'description' => __( 'Horizontal flip direction. 0 for no flip, 1 for flip.', 'gutenberg' ), + 'type' => 'number', + ), + 'vertical' => array( + 'description' => __( 'Vertical flip direction. 0 for no flip, 1 for flip.', 'gutenberg' ), + 'type' => 'number', + ), + ), + ), + ), + ), + ), + ), + array( + 'title' => __( 'Rotation', 'gutenberg' ), 'properties' => array( 'type' => array( - 'description' => __( 'Rotation type.' ), + 'description' => __( 'Rotation type.', 'gutenberg' ), 'type' => 'string', 'enum' => array( 'rotate' ), ), 'args' => array( - 'description' => __( 'Rotation arguments.' ), + 'description' => __( 'Rotation arguments.', 'gutenberg' ), 'type' => 'object', 'required' => array( 'angle', ), 'properties' => array( 'angle' => array( - 'description' => __( 'Angle to rotate clockwise in degrees.' ), + 'description' => __( 'Angle to rotate clockwise in degrees.', 'gutenberg' ), 'type' => 'number', ), ), @@ -1527,15 +1604,15 @@ protected function get_edit_media_item_args() { ), ), array( - 'title' => __( 'Crop' ), + 'title' => __( 'Crop', 'gutenberg' ), 'properties' => array( 'type' => array( - 'description' => __( 'Crop type.' ), + 'description' => __( 'Crop type.', 'gutenberg' ), 'type' => 'string', 'enum' => array( 'crop' ), ), 'args' => array( - 'description' => __( 'Crop arguments.' ), + 'description' => __( 'Crop arguments.', 'gutenberg' ), 'type' => 'object', 'required' => array( 'left', @@ -1545,19 +1622,19 @@ protected function get_edit_media_item_args() { ), 'properties' => array( 'left' => array( - 'description' => __( 'Horizontal position from the left to begin the crop as a percentage of the image width.' ), + 'description' => __( 'Horizontal position from the left to begin the crop as a percentage of the image width.', 'gutenberg' ), 'type' => 'number', ), 'top' => array( - 'description' => __( 'Vertical position from the top to begin the crop as a percentage of the image height.' ), + 'description' => __( 'Vertical position from the top to begin the crop as a percentage of the image height.', 'gutenberg' ), 'type' => 'number', ), 'width' => array( - 'description' => __( 'Width of the crop as a percentage of the image width.' ), + 'description' => __( 'Width of the crop as a percentage of the image width.', 'gutenberg' ), 'type' => 'number', ), 'height' => array( - 'description' => __( 'Height of the crop as a percentage of the image height.' ), + 'description' => __( 'Height of the crop as a percentage of the image height.', 'gutenberg' ), 'type' => 'number', ), ), @@ -1568,7 +1645,7 @@ protected function get_edit_media_item_args() { ), ), 'rotation' => array( - 'description' => __( 'The amount to rotate the image clockwise in degrees. DEPRECATED: Use `modifiers` instead.' ), + 'description' => __( 'The amount to rotate the image clockwise in degrees. DEPRECATED: Use `modifiers` instead.', 'gutenberg' ), 'type' => 'integer', 'minimum' => 0, 'exclusiveMinimum' => true, @@ -1576,29 +1653,57 @@ protected function get_edit_media_item_args() { 'exclusiveMaximum' => true, ), 'x' => array( - 'description' => __( 'As a percentage of the image, the x position to start the crop from. DEPRECATED: Use `modifiers` instead.' ), + 'description' => __( 'As a percentage of the image, the x position to start the crop from. DEPRECATED: Use `modifiers` instead.', 'gutenberg' ), 'type' => 'number', 'minimum' => 0, 'maximum' => 100, ), 'y' => array( - 'description' => __( 'As a percentage of the image, the y position to start the crop from. DEPRECATED: Use `modifiers` instead.' ), + 'description' => __( 'As a percentage of the image, the y position to start the crop from. DEPRECATED: Use `modifiers` instead.', 'gutenberg' ), 'type' => 'number', 'minimum' => 0, 'maximum' => 100, ), 'width' => array( - 'description' => __( 'As a percentage of the image, the width to crop the image to. DEPRECATED: Use `modifiers` instead.' ), + 'description' => __( 'As a percentage of the image, the width to crop the image to. DEPRECATED: Use `modifiers` instead.', 'gutenberg' ), 'type' => 'number', 'minimum' => 0, 'maximum' => 100, ), 'height' => array( - 'description' => __( 'As a percentage of the image, the height to crop the image to. DEPRECATED: Use `modifiers` instead.' ), + 'description' => __( 'As a percentage of the image, the height to crop the image to. DEPRECATED: Use `modifiers` instead.', 'gutenberg' ), 'type' => 'number', 'minimum' => 0, 'maximum' => 100, ), ); + + /* + * Get the args based on the post schema. This calls `rest_get_endpoint_args_for_schema()`, + * which also takes care of sanitization and validation. + */ + $update_item_args = $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ); + + if ( isset( $update_item_args['caption'] ) ) { + $args['caption'] = $update_item_args['caption']; + } + + if ( isset( $update_item_args['description'] ) ) { + $args['description'] = $update_item_args['description']; + } + + if ( isset( $update_item_args['title'] ) ) { + $args['title'] = $update_item_args['title']; + } + + if ( isset( $update_item_args['post'] ) ) { + $args['post'] = $update_item_args['post']; + } + + if ( isset( $update_item_args['alt_text'] ) ) { + $args['alt_text'] = $update_item_args['alt_text']; + } + + return $args; } } diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 3bc9f798e6d7e..e0301156c59a5 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -2682,4 +2682,118 @@ public function test_upload_svg_image() { $this->assertTrue( $result ); } + + /** + * Tests that the attachment fields caption, description, and title, post and alt_text are updated correctly. + * @ticket 64035 + * @requires function imagejpeg + */ + public function test_edit_image_updates_attachment_fields() { + wp_set_current_user( self::$superadmin_id ); + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + + // In order to test the edit endpoint editable fields, we need to create a new attachment. + $params = array( + 'src' => wp_get_attachment_image_url( $attachment, 'full' ), + 'modifiers' => array( + array( + 'type' => 'crop', + 'args' => array( + 'left' => 10, + 'top' => 10, + 'width' => 80, + 'height' => 80, + ), + ), + ), + 'caption' => 'Test Caption', + 'description' => 'Test Description', + 'title' => 'Test Title', + 'post' => 1, + 'alt_text' => 'Test Alt Text', + ); + + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment}/edit" ); + $request->set_body_params( $params ); + $response = rest_do_request( $request ); + + // The edit endpoint creates a new attachment, so we expect a 201 status. + $this->assertEquals( 201, $response->get_status() ); + + $data = $response->get_data(); + $new_attachment_id = $data['id']; + + $updated_attachment = get_post( $new_attachment_id ); + + $this->assertSame( 'Test Title', $updated_attachment->post_title, 'Title of the updated attachment is not identical.' ); + + $this->assertSame( 'Test Caption', $updated_attachment->post_excerpt, 'Caption of the updated attachment is not identical.' ); + + $this->assertSame( 'Test Description', $updated_attachment->post_content, 'Description of the updated attachment is not identical.' ); + + $this->assertSame( 1, $updated_attachment->post_parent, 'Post parent of the updated attachment is not identical.' ); + + $this->assertSame( 'Test Alt Text', get_post_meta( $new_attachment_id, '_wp_attachment_image_alt', true ), 'Alt text of the updated attachment is not identical.' ); + } + + /** + * Tests that the image is flipped correctly vertically and horizontally. + * + * @ticket 64035 + * @requires function imagejpeg + */ + public function test_edit_image_vertical_and_horizontal_flip() { + wp_set_current_user( self::$superadmin_id ); + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + + $this->setup_mock_editor(); + WP_Image_Editor_Mock::$edit_return['flip'] = new WP_Error(); + + $params = array( + 'flip' => array( + 'vertical' => 1, + 'horizontal' => 1, + ), + 'src' => wp_get_attachment_image_url( $attachment, 'full' ), + ); + + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment}/edit" ); + $request->set_body_params( $params ); + $response = rest_do_request( $request ); + $this->assertErrorResponse( 'rest_image_flip_failed', $response, 500 ); + + $this->assertCount( 1, WP_Image_Editor_Mock::$spy['flip'] ); + // The controller converts the integer values to booleans: 0 !== (int) 1 = true. + $this->assertSame( array( true, true ), WP_Image_Editor_Mock::$spy['flip'][0], 'Vertical and horizontal flip of the image is not identical.' ); + } + + /** + * Tests that the image is flipped correctly vertically. + * + * @ticket 64035 + * @requires function imagejpeg + */ + public function test_edit_image_vertical_flip() { + wp_set_current_user( self::$superadmin_id ); + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + + $this->setup_mock_editor(); + WP_Image_Editor_Mock::$edit_return['flip'] = new WP_Error(); + + $params = array( + 'flip' => array( + 'vertical' => 1, + ), + 'src' => wp_get_attachment_image_url( $attachment, 'full' ), + ); + + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment}/edit" ); + $request->set_body_params( $params ); + $response = rest_do_request( $request ); + $this->assertErrorResponse( 'rest_image_flip_failed', $response, 500 ); + + $this->assertCount( 1, WP_Image_Editor_Mock::$spy['flip'] ); + // The controller converts the integer values to booleans: 0 !== (int) 1 = true. + $this->assertSame( array( true, false ), WP_Image_Editor_Mock::$spy['flip'][0], 'Vertical flip of the image is not identical.' ); + } } From 9f85466bdf4dda1279d97eb9eb8628d984c1e1a3 Mon Sep 17 00:00:00 2001 From: Ramon Date: Thu, 25 Sep 2025 15:13:37 +1000 Subject: [PATCH 2/8] Remove text domain from translation strings in WP REST Attachments Controller. --- .../class-wp-rest-attachments-controller.php | 64 +++++++++---------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index a45f05f572fb7..dfe6e52ffdede 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -564,7 +564,7 @@ public function edit_media_item( $request ) { ) { return new WP_Error( 'rest_unknown_attachment', - __( 'Unable to get meta information for file.', 'gutenberg' ), + __( 'Unable to get meta information for file.' ), array( 'status' => 404 ) ); } @@ -574,7 +574,7 @@ public function edit_media_item( $request ) { if ( ! in_array( $mime_type, $supported_types, true ) ) { return new WP_Error( 'rest_cannot_edit_file_type', - __( 'This type of file cannot be edited.', 'gutenberg' ), + __( 'This type of file cannot be edited.' ), array( 'status' => 400 ) ); } @@ -623,7 +623,7 @@ public function edit_media_item( $request ) { if ( 0 === count( $modifiers ) ) { return new WP_Error( 'rest_image_not_edited', - __( 'The image was not edited. Edit the image before applying the changes.', 'gutenberg' ), + __( 'The image was not edited. Edit the image before applying the changes.' ), array( 'status' => 400 ) ); } @@ -644,7 +644,7 @@ public function edit_media_item( $request ) { if ( is_wp_error( $image_editor ) ) { return new WP_Error( 'rest_unknown_image_file_type', - __( 'Unable to edit this image.', 'gutenberg' ), + __( 'Unable to edit this image.' ), array( 'status' => 500 ) ); } @@ -662,7 +662,7 @@ public function edit_media_item( $request ) { if ( is_wp_error( $result ) ) { return new WP_Error( 'rest_image_flip_failed', - __( 'Unable to flip this image.', 'gutenberg' ), + __( 'Unable to flip this image.' ), array( 'status' => 500 ) ); } @@ -677,7 +677,7 @@ public function edit_media_item( $request ) { if ( is_wp_error( $result ) ) { return new WP_Error( 'rest_image_rotation_failed', - __( 'Unable to rotate this image.', 'gutenberg' ), + __( 'Unable to rotate this image.' ), array( 'status' => 500 ) ); } @@ -699,7 +699,7 @@ public function edit_media_item( $request ) { if ( is_wp_error( $result ) ) { return new WP_Error( 'rest_image_crop_failed', - __( 'Unable to crop this image.', 'gutenberg' ), + __( 'Unable to crop this image.' ), array( 'status' => 500 ) ); } @@ -1525,18 +1525,18 @@ protected function check_upload_size( $file ) { protected function get_edit_media_item_args() { $args = array( 'src' => array( - 'description' => __( 'URL to the edited image file.', 'gutenberg' ), + 'description' => __( 'URL to the edited image file.' ), 'type' => 'string', 'format' => 'uri', 'required' => true, ), // The `modifiers` param takes precedence over the older format. 'modifiers' => array( - 'description' => __( 'Array of image edits.', 'gutenberg' ), + 'description' => __( 'Array of image edits.' ), 'type' => 'array', 'minItems' => 1, 'items' => array( - 'description' => __( 'Image edit.', 'gutenberg' ), + 'description' => __( 'Image edit.' ), 'type' => 'object', 'required' => array( 'type', @@ -1544,22 +1544,22 @@ protected function get_edit_media_item_args() { ), 'oneOf' => array( array( - 'title' => __( 'Flip', 'gutenberg' ), + 'title' => __( 'Flip' ), 'properties' => array( 'type' => array( - 'description' => __( 'Flip type.', 'gutenberg' ), + 'description' => __( 'Flip type.' ), 'type' => 'string', 'enum' => array( 'flip' ), ), 'args' => array( - 'description' => __( 'Flip arguments.', 'gutenberg' ), + 'description' => __( 'Flip arguments.' ), 'type' => 'object', 'required' => array( 'flip', ), 'properties' => array( 'flip' => array( - 'description' => __( 'Flip direction. [ horizontal, vertical ] 0 for no flip, 1 for flip.', 'gutenberg' ), + 'description' => __( 'Flip direction. [ horizontal, vertical ] 0 for no flip, 1 for flip.' ), 'type' => 'object', 'required' => array( 'horizontal', @@ -1567,11 +1567,11 @@ protected function get_edit_media_item_args() { ), 'properties' => array( 'horizontal' => array( - 'description' => __( 'Horizontal flip direction. 0 for no flip, 1 for flip.', 'gutenberg' ), + 'description' => __( 'Horizontal flip direction. 0 for no flip, 1 for flip.' ), 'type' => 'number', ), 'vertical' => array( - 'description' => __( 'Vertical flip direction. 0 for no flip, 1 for flip.', 'gutenberg' ), + 'description' => __( 'Vertical flip direction. 0 for no flip, 1 for flip.' ), 'type' => 'number', ), ), @@ -1581,22 +1581,22 @@ protected function get_edit_media_item_args() { ), ), array( - 'title' => __( 'Rotation', 'gutenberg' ), + 'title' => __( 'Rotation' ), 'properties' => array( 'type' => array( - 'description' => __( 'Rotation type.', 'gutenberg' ), + 'description' => __( 'Rotation type.' ), 'type' => 'string', 'enum' => array( 'rotate' ), ), 'args' => array( - 'description' => __( 'Rotation arguments.', 'gutenberg' ), + 'description' => __( 'Rotation arguments.' ), 'type' => 'object', 'required' => array( 'angle', ), 'properties' => array( 'angle' => array( - 'description' => __( 'Angle to rotate clockwise in degrees.', 'gutenberg' ), + 'description' => __( 'Angle to rotate clockwise in degrees.' ), 'type' => 'number', ), ), @@ -1604,15 +1604,15 @@ protected function get_edit_media_item_args() { ), ), array( - 'title' => __( 'Crop', 'gutenberg' ), + 'title' => __( 'Crop' ), 'properties' => array( 'type' => array( - 'description' => __( 'Crop type.', 'gutenberg' ), + 'description' => __( 'Crop type.' ), 'type' => 'string', 'enum' => array( 'crop' ), ), 'args' => array( - 'description' => __( 'Crop arguments.', 'gutenberg' ), + 'description' => __( 'Crop arguments.' ), 'type' => 'object', 'required' => array( 'left', @@ -1622,19 +1622,19 @@ protected function get_edit_media_item_args() { ), 'properties' => array( 'left' => array( - 'description' => __( 'Horizontal position from the left to begin the crop as a percentage of the image width.', 'gutenberg' ), + 'description' => __( 'Horizontal position from the left to begin the crop as a percentage of the image width.' ), 'type' => 'number', ), 'top' => array( - 'description' => __( 'Vertical position from the top to begin the crop as a percentage of the image height.', 'gutenberg' ), + 'description' => __( 'Vertical position from the top to begin the crop as a percentage of the image height.' ), 'type' => 'number', ), 'width' => array( - 'description' => __( 'Width of the crop as a percentage of the image width.', 'gutenberg' ), + 'description' => __( 'Width of the crop as a percentage of the image width.' ), 'type' => 'number', ), 'height' => array( - 'description' => __( 'Height of the crop as a percentage of the image height.', 'gutenberg' ), + 'description' => __( 'Height of the crop as a percentage of the image height.' ), 'type' => 'number', ), ), @@ -1645,7 +1645,7 @@ protected function get_edit_media_item_args() { ), ), 'rotation' => array( - 'description' => __( 'The amount to rotate the image clockwise in degrees. DEPRECATED: Use `modifiers` instead.', 'gutenberg' ), + 'description' => __( 'The amount to rotate the image clockwise in degrees. DEPRECATED: Use `modifiers` instead.' ), 'type' => 'integer', 'minimum' => 0, 'exclusiveMinimum' => true, @@ -1653,25 +1653,25 @@ protected function get_edit_media_item_args() { 'exclusiveMaximum' => true, ), 'x' => array( - 'description' => __( 'As a percentage of the image, the x position to start the crop from. DEPRECATED: Use `modifiers` instead.', 'gutenberg' ), + 'description' => __( 'As a percentage of the image, the x position to start the crop from. DEPRECATED: Use `modifiers` instead.' ), 'type' => 'number', 'minimum' => 0, 'maximum' => 100, ), 'y' => array( - 'description' => __( 'As a percentage of the image, the y position to start the crop from. DEPRECATED: Use `modifiers` instead.', 'gutenberg' ), + 'description' => __( 'As a percentage of the image, the y position to start the crop from. DEPRECATED: Use `modifiers` instead.' ), 'type' => 'number', 'minimum' => 0, 'maximum' => 100, ), 'width' => array( - 'description' => __( 'As a percentage of the image, the width to crop the image to. DEPRECATED: Use `modifiers` instead.', 'gutenberg' ), + 'description' => __( 'As a percentage of the image, the width to crop the image to. DEPRECATED: Use `modifiers` instead.' ), 'type' => 'number', 'minimum' => 0, 'maximum' => 100, ), 'height' => array( - 'description' => __( 'As a percentage of the image, the height to crop the image to. DEPRECATED: Use `modifiers` instead.', 'gutenberg' ), + 'description' => __( 'As a percentage of the image, the height to crop the image to. DEPRECATED: Use `modifiers` instead.' ), 'type' => 'number', 'minimum' => 0, 'maximum' => 100, From 8f529438005ca5132964e4e5fb0a18f99108f7fe Mon Sep 17 00:00:00 2001 From: Ramon Date: Thu, 25 Sep 2025 15:14:27 +1000 Subject: [PATCH 3/8] Remove unnecessary PHPCS ignore comment from the WP REST Attachments Controller. --- .../rest-api/endpoints/class-wp-rest-attachments-controller.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index dfe6e52ffdede..0ff74561757ff 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -827,7 +827,6 @@ public function edit_media_item( $request ) { * @param int $new_attachment_id Attachment post ID for the new image. * @param int $attachment_id Attachment post ID for the edited (parent) image. */ - // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound $new_image_meta = apply_filters( 'wp_edited_image_metadata', $new_image_meta, $new_attachment_id, $attachment_id ); wp_update_attachment_metadata( $new_attachment_id, $new_image_meta ); From 9bacea956ea1071b651c5a3895049c7585c7aafa Mon Sep 17 00:00:00 2001 From: Ramon Date: Thu, 25 Sep 2025 16:02:41 +1000 Subject: [PATCH 4/8] Update mocked API response for attachments --- tests/qunit/fixtures/wp-api-generated.js | 128 +++++++++++++++++++++-- 1 file changed, 121 insertions(+), 7 deletions(-) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 6626758a8a9dc..800762dc3479e 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -21,13 +21,7 @@ mockedApiResponse.Schema = { "wp-site-health/v1", "wp-block-editor/v1" ], - "authentication": { - "application-passwords": { - "endpoints": { - "authorization": "http://example.org/wp-admin/authorize-application.php" - } - } - }, + "authentication": [], "routes": { "/": { "namespace": "", @@ -3427,6 +3421,45 @@ mockedApiResponse.Schema = { "args" ], "oneOf": [ + { + "title": "Flip", + "properties": { + "type": { + "description": "Flip type.", + "type": "string", + "enum": [ + "flip" + ] + }, + "args": { + "description": "Flip arguments.", + "type": "object", + "required": [ + "flip" + ], + "properties": { + "flip": { + "description": "Flip direction. [ horizontal, vertical ] 0 for no flip, 1 for flip.", + "type": "object", + "required": [ + "horizontal", + "vertical" + ], + "properties": { + "horizontal": { + "description": "Horizontal flip direction. 0 for no flip, 1 for flip.", + "type": "number" + }, + "vertical": { + "description": "Vertical flip direction. 0 for no flip, 1 for flip.", + "type": "number" + } + } + } + } + } + } + }, { "title": "Rotation", "properties": { @@ -3532,6 +3565,87 @@ mockedApiResponse.Schema = { "minimum": 0, "maximum": 100, "required": false + }, + "caption": { + "description": "The attachment caption.", + "type": "object", + "properties": { + "raw": { + "description": "Caption for the attachment, as it exists in the database.", + "type": "string", + "context": [ + "edit" + ] + }, + "rendered": { + "description": "HTML caption for the attachment, transformed for display.", + "type": "string", + "context": [ + "view", + "edit", + "embed" + ], + "readonly": true + } + }, + "required": false + }, + "description": { + "description": "The attachment description.", + "type": "object", + "properties": { + "raw": { + "description": "Description for the attachment, as it exists in the database.", + "type": "string", + "context": [ + "edit" + ] + }, + "rendered": { + "description": "HTML description for the attachment, transformed for display.", + "type": "string", + "context": [ + "view", + "edit" + ], + "readonly": true + } + }, + "required": false + }, + "title": { + "description": "The title for the post.", + "type": "object", + "properties": { + "raw": { + "description": "Title for the post, as it exists in the database.", + "type": "string", + "context": [ + "edit" + ] + }, + "rendered": { + "description": "HTML title for the post, transformed for display.", + "type": "string", + "context": [ + "view", + "edit", + "embed" + ], + "readonly": true + } + }, + "required": false + }, + "post": { + "description": "The ID for the associated post of the attachment.", + "type": "integer", + "required": false + }, + "alt_text": { + "description": "Alternative text to display when attachment is not displayed.", + "type": "string", + "required": false } } } From 0c1a756b12dadd67331df923dcf6ca11f2691116 Mon Sep 17 00:00:00 2001 From: Ramon Date: Thu, 25 Sep 2025 16:22:44 +1000 Subject: [PATCH 5/8] Reinstate mocked API response to include application passwords authentication endpoints. --- tests/qunit/fixtures/wp-api-generated.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 800762dc3479e..1cf6cfd87b30c 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -21,7 +21,13 @@ mockedApiResponse.Schema = { "wp-site-health/v1", "wp-block-editor/v1" ], - "authentication": [], + "authentication": { + "application-passwords": { + "endpoints": { + "authorization": "http://example.org/wp-admin/authorize-application.php" + } + } + }, "routes": { "/": { "namespace": "", From 7a91a74021427bd65e6f88f1b8168baa5ad78bf1 Mon Sep 17 00:00:00 2001 From: Ramon Date: Fri, 26 Sep 2025 15:20:24 +1000 Subject: [PATCH 6/8] Refactor flip parameters in WP REST Attachments Controller to use boolean values instead of integers. Update related tests to reflect changes in parameter types and description --- .../class-wp-rest-attachments-controller.php | 16 ++++---- .../rest-api/rest-attachments-controller.php | 41 ++++++++++++++++--- tests/qunit/fixtures/wp-api-generated.js | 10 ++--- 3 files changed, 49 insertions(+), 18 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index 0ff74561757ff..b7ed79a6c9437 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -587,8 +587,8 @@ public function edit_media_item( $request ) { if ( isset( $request['flip']['horizontal'] ) || isset( $request['flip']['vertical'] ) ) { $flip_args = array( - 'vertical' => $request['flip']['vertical'] ?? 0, - 'horizontal' => $request['flip']['horizontal'] ?? 0, + 'vertical' => isset( $request['flip']['vertical'] ) ? (bool) $request['flip']['vertical'] : false, + 'horizontal' => isset( $request['flip']['horizontal'] ) ? (bool) $request['flip']['horizontal'] : false, ); $modifiers[] = array( @@ -658,7 +658,7 @@ public function edit_media_item( $request ) { * The vertical flip is the first argument (flip along horizontal axis), the horizontal flip is the second argument (flip along vertical axis). * See: WP_Image_Editor::flip() */ - $result = $image_editor->flip( 0 !== (int) $args['flip']['vertical'], 0 !== (int) $args['flip']['horizontal'] ); + $result = $image_editor->flip( $args['flip']['vertical'], $args['flip']['horizontal'] ); if ( is_wp_error( $result ) ) { return new WP_Error( 'rest_image_flip_failed', @@ -1558,7 +1558,7 @@ protected function get_edit_media_item_args() { ), 'properties' => array( 'flip' => array( - 'description' => __( 'Flip direction. [ horizontal, vertical ] 0 for no flip, 1 for flip.' ), + 'description' => __( 'Flip direction. [ horizontal, vertical ]' ), 'type' => 'object', 'required' => array( 'horizontal', @@ -1566,12 +1566,12 @@ protected function get_edit_media_item_args() { ), 'properties' => array( 'horizontal' => array( - 'description' => __( 'Horizontal flip direction. 0 for no flip, 1 for flip.' ), - 'type' => 'number', + 'description' => __( 'Whether to flip in the horizontal direction.' ), + 'type' => 'boolean', ), 'vertical' => array( - 'description' => __( 'Vertical flip direction. 0 for no flip, 1 for flip.' ), - 'type' => 'number', + 'description' => __( 'Whether to flip in the vertical direction.' ), + 'type' => 'boolean', ), ), ), diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index e0301156c59a5..3e69011901316 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -2751,8 +2751,8 @@ public function test_edit_image_vertical_and_horizontal_flip() { $params = array( 'flip' => array( - 'vertical' => 1, - 'horizontal' => 1, + 'vertical' => true, + 'horizontal' => true, ), 'src' => wp_get_attachment_image_url( $attachment, 'full' ), ); @@ -2768,12 +2768,12 @@ public function test_edit_image_vertical_and_horizontal_flip() { } /** - * Tests that the image is flipped correctly vertically. + * Tests that the image is flipped correctly vertically only. * * @ticket 64035 * @requires function imagejpeg */ - public function test_edit_image_vertical_flip() { + public function test_edit_image_vertical_flip_with_horizontal_false() { wp_set_current_user( self::$superadmin_id ); $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); @@ -2782,7 +2782,38 @@ public function test_edit_image_vertical_flip() { $params = array( 'flip' => array( - 'vertical' => 1, + 'vertical' => true, + 'horizontal' => false, + ), + 'src' => wp_get_attachment_image_url( $attachment, 'full' ), + ); + + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment}/edit" ); + $request->set_body_params( $params ); + $response = rest_do_request( $request ); + $this->assertErrorResponse( 'rest_image_flip_failed', $response, 500 ); + + $this->assertCount( 1, WP_Image_Editor_Mock::$spy['flip'] ); + // The controller converts the integer values to booleans: 0 !== (int) 1 = true. + $this->assertSame( array( true, false ), WP_Image_Editor_Mock::$spy['flip'][0], 'Vertical flip of the image is not identical.' ); + } + + /** + * Tests that the image is flipped correctly with only vertical flip in arguments. + * + * @ticket 64035 + * @requires function imagejpeg + */ + public function test_edit_image_vertical_flip_only() { + wp_set_current_user( self::$superadmin_id ); + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + + $this->setup_mock_editor(); + WP_Image_Editor_Mock::$edit_return['flip'] = new WP_Error(); + + $params = array( + 'flip' => array( + 'vertical' => true, ), 'src' => wp_get_attachment_image_url( $attachment, 'full' ), ); diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 1cf6cfd87b30c..49683f0f96619 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -3445,7 +3445,7 @@ mockedApiResponse.Schema = { ], "properties": { "flip": { - "description": "Flip direction. [ horizontal, vertical ] 0 for no flip, 1 for flip.", + "description": "Flip direction. [ horizontal, vertical ]", "type": "object", "required": [ "horizontal", @@ -3453,12 +3453,12 @@ mockedApiResponse.Schema = { ], "properties": { "horizontal": { - "description": "Horizontal flip direction. 0 for no flip, 1 for flip.", - "type": "number" + "description": "Whether to flip in the horizontal direction.", + "type": "boolean" }, "vertical": { - "description": "Vertical flip direction. 0 for no flip, 1 for flip.", - "type": "number" + "description": "Whether to flip in the vertical direction.", + "type": "boolean" } } } From 2c0846da62947bd72013a19c97b40eba264972cc Mon Sep 17 00:00:00 2001 From: Ramon Date: Fri, 26 Sep 2025 15:23:51 +1000 Subject: [PATCH 7/8] Fix formatting issue in WP REST Attachments Controller test by removing unnecessary whitespace at the end of a method. --- tests/phpunit/tests/rest-api/rest-attachments-controller.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 3e69011901316..fb8f1de983fd1 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -2796,7 +2796,7 @@ public function test_edit_image_vertical_flip_with_horizontal_false() { $this->assertCount( 1, WP_Image_Editor_Mock::$spy['flip'] ); // The controller converts the integer values to booleans: 0 !== (int) 1 = true. $this->assertSame( array( true, false ), WP_Image_Editor_Mock::$spy['flip'][0], 'Vertical flip of the image is not identical.' ); - } + } /** * Tests that the image is flipped correctly with only vertical flip in arguments. From 73578f1c0fb72b98ed1a763f57b3a6a864551443 Mon Sep 17 00:00:00 2001 From: Ramon Date: Tue, 7 Oct 2025 10:22:56 +1100 Subject: [PATCH 8/8] Update flip parameter description in WP REST Attachments Controller and corresponding test fixture to remove unnecessary details. --- .../rest-api/endpoints/class-wp-rest-attachments-controller.php | 2 +- tests/qunit/fixtures/wp-api-generated.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index b7ed79a6c9437..1bc0a539a02b4 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -1558,7 +1558,7 @@ protected function get_edit_media_item_args() { ), 'properties' => array( 'flip' => array( - 'description' => __( 'Flip direction. [ horizontal, vertical ]' ), + 'description' => __( 'Flip direction.' ), 'type' => 'object', 'required' => array( 'horizontal', diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 49683f0f96619..19405f0567924 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -3445,7 +3445,7 @@ mockedApiResponse.Schema = { ], "properties": { "flip": { - "description": "Flip direction. [ horizontal, vertical ]", + "description": "Flip direction.", "type": "object", "required": [ "horizontal",