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..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 @@ -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. @@ -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' => isset( $request['flip']['vertical'] ) ? (bool) $request['flip']['vertical'] : false, + 'horizontal' => isset( $request['flip']['horizontal'] ) ? (bool) $request['flip']['horizontal'] : false, + ); + + $modifiers[] = array( + 'type' => 'flip', + 'args' => array( + 'flip' => $flip_args, + ), + ); + } + if ( ! empty( $request['rotation'] ) ) { $modifiers[] = array( 'type' => 'rotate', @@ -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( $args['flip']['vertical'], $args['flip']['horizontal'] ); + if ( is_wp_error( $result ) ) { + return new WP_Error( + 'rest_image_flip_failed', + __( 'Unable to flip this image.' ), + array( 'status' => 500 ) + ); + } + break; case 'rotate': // Rotation direction: clockwise vs. counterclockwise. $rotate = 0 - $args['angle']; @@ -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. @@ -1480,17 +1517,19 @@ 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.' ), 'type' => 'string', 'format' => 'uri', 'required' => true, ), + // The `modifiers` param takes precedence over the older format. 'modifiers' => array( 'description' => __( 'Array of image edits.' ), 'type' => 'array', @@ -1503,6 +1542,43 @@ protected function get_edit_media_item_args() { 'args', ), 'oneOf' => array( + array( + 'title' => __( 'Flip' ), + 'properties' => array( + 'type' => array( + 'description' => __( 'Flip type.' ), + 'type' => 'string', + 'enum' => array( 'flip' ), + ), + 'args' => array( + 'description' => __( 'Flip arguments.' ), + 'type' => 'object', + 'required' => array( + 'flip', + ), + 'properties' => array( + 'flip' => array( + 'description' => __( 'Flip direction.' ), + 'type' => 'object', + 'required' => array( + 'horizontal', + 'vertical', + ), + 'properties' => array( + 'horizontal' => array( + 'description' => __( 'Whether to flip in the horizontal direction.' ), + 'type' => 'boolean', + ), + 'vertical' => array( + 'description' => __( 'Whether to flip in the vertical direction.' ), + 'type' => 'boolean', + ), + ), + ), + ), + ), + ), + ), array( 'title' => __( 'Rotation' ), 'properties' => array( @@ -1600,5 +1676,33 @@ protected function get_edit_media_item_args() { '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..fb8f1de983fd1 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -2682,4 +2682,149 @@ 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' => true, + 'horizontal' => true, + ), + '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 only. + * + * @ticket 64035 + * @requires function imagejpeg + */ + 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 ); + + $this->setup_mock_editor(); + WP_Image_Editor_Mock::$edit_return['flip'] = new WP_Error(); + + $params = array( + 'flip' => array( + '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' ), + ); + + $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.' ); + } } diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 6626758a8a9dc..19405f0567924 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -3427,6 +3427,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.", + "type": "object", + "required": [ + "horizontal", + "vertical" + ], + "properties": { + "horizontal": { + "description": "Whether to flip in the horizontal direction.", + "type": "boolean" + }, + "vertical": { + "description": "Whether to flip in the vertical direction.", + "type": "boolean" + } + } + } + } + } + } + }, { "title": "Rotation", "properties": { @@ -3532,6 +3571,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 } } }