From a87f7c3bc9bf917a3fb09a0e46bee18bfbcc0272 Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 31 Aug 2026 12:35:09 +1000 Subject: [PATCH 1/7] Media: Track the original attachment for edited images. Editing an image via the `wp/v2/media//edit` REST endpoint saves the result as a new attachment and leaves the edited image untouched, so a site can build up a chain: an upload, a crop of it, a crop of that crop. `parent_image` records only the immediately preceding image, so finding the image a chain started from meant walking it one attachment at a time. Each attachment created by an edit now records the ID at the top of its chain in `_wp_attachment_original_id` postmeta, inheriting it from the image being edited. `wp_get_original_attachment_id()` reads it back in a single lookup, and returns the ID it was given for attachments that were uploaded rather than edited. The attachments REST controller exposes the result as an `original_attachment` field in the `edit` context only, giving editors what they need to offer a way back to the original without telling visitors which images were made from which. Deleting an attachment clears the record from any image edited from it, so nothing is left pointing at an ID that could later be reused. Records are written going forward only; images edited before this lands are not backfilled. Fixes #65987. --- src/wp-includes/default-filters.php | 1 + src/wp-includes/post.php | 57 ++++++ .../class-wp-rest-attachments-controller.php | 60 +++++++ .../rest-api/rest-attachments-controller.php | 165 ++++++++++++++++++ 4 files changed, 283 insertions(+) diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 025a371781200..7d651b90f64bf 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -696,6 +696,7 @@ add_action( 'customize_controls_enqueue_scripts', 'wp_plupload_default_settings' ); add_action( 'plugins_loaded', '_wp_add_additional_image_sizes', 0 ); add_filter( 'plupload_default_settings', 'wp_show_heic_upload_error' ); +add_action( 'delete_attachment', '_wp_delete_original_attachment_id' ); // Client-side media processing. add_action( 'admin_init', 'wp_set_client_side_media_processing_flag' ); diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index aeea4e5bafac2..833cecb6430f0 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -8826,6 +8826,63 @@ function wp_get_original_image_url( $attachment_id ) { return apply_filters( 'wp_get_original_image_url', $original_image_url, $attachment_id ); } +/** + * Retrieves the ID of the attachment an edited image originally came from. + * + * Editing an image through the `wp/v2/media//edit` REST endpoint does not change the + * image that was edited. It saves the result as a brand new attachment, so a site can end + * up with a chain of attachments: an upload, a crop of it, a crop of that crop, and so on. + * + * Every attachment created that way stores the ID of the attachment at the top of its chain, + * so this function can find the original in one lookup no matter how long the chain is. + * + * Attachments that were uploaded rather than created by editing have no chain of their own, + * and this returns the ID that was passed in. To tell the two cases apart, compare the + * result against that ID. + * + * @since 7.2.0 + * + * @param int $attachment_id Attachment ID. + * @return int ID of the attachment the chain started from, or `$attachment_id` when the + * attachment was not created by editing another one. + */ +function wp_get_original_attachment_id( $attachment_id ) { + $original_id = (int) get_post_meta( $attachment_id, '_wp_attachment_original_id', true ); + + return $original_id > 0 ? $original_id : (int) $attachment_id; +} + +/** + * Clears the recorded original attachment ID from any attachment pointing at a deleted one. + * + * Without this, attachments created by editing the deleted image would keep pointing at an + * ID that no longer exists, and could later point at an unrelated attachment if WordPress + * reuses that ID. + * + * This only runs when an attachment is deleted for good. On sites where media goes to the + * trash first, attachments keep pointing at the trashed original until the trash is emptied. + * + * @since 7.2.0 + * + * @access private + * + * @param int $post_id Attachment ID being deleted. + */ +function _wp_delete_original_attachment_id( $post_id ) { + $post_id = (int) $post_id; + + if ( $post_id <= 0 ) { + return; + } + + /* + * Deletes the meta from every attachment recording this ID as its original. The meta key + * is indexed, so this only scans the rows for attachments created by editing an image, + * and it avoids searching the serialized attachment metadata for the ID. + */ + delete_metadata( 'post', 0, '_wp_attachment_original_id', $post_id, true ); +} + /** * Filters callback which sets the status of an untrashed post to its previous status. * 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 9807ac9cf15b5..d1aa6c6bd6293 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 @@ -1356,6 +1356,18 @@ public function edit_media_item( $request ) { 'file' => _wp_relative_upload_path( $image_file ), ); + /* + * Record the attachment this chain of edits started from, so the original can be + * found in one lookup from any image later in the chain. The new attachment inherits + * the original recorded on the image being edited, or that image itself when it was + * uploaded rather than edited. + */ + update_post_meta( + $new_attachment_id, + '_wp_attachment_original_id', + wp_get_original_attachment_id( $attachment_id ) + ); + /** * Filters the meta data for the new image created by editing an existing image. * @@ -1503,6 +1515,31 @@ public function prepare_item_for_response( $item, $request ) { } else { $data['media_details']['sizes'] = new stdClass(); } + + /* + * Point an image created by editing another one back at the attachment its chain + * of edits started from, so editors can offer a way to get back to the original. + * + * Only sent in the `edit` context: this is for people editing the image, and it + * would otherwise tell visitors which images were made from which. + * + * Left out when the attachment was not created by editing another one, and when + * the original no longer has a URL, which happens if its file is missing. + */ + if ( 'edit' === $request['context'] && is_array( $data['media_details'] ) ) { + $original_id = wp_get_original_attachment_id( $post->ID ); + + if ( $original_id !== (int) $post->ID ) { + $original_url = wp_get_attachment_url( $original_id ); + + if ( is_string( $original_url ) && '' !== $original_url ) { + $data['media_details']['original_attachment'] = array( + 'attachment_id' => $original_id, + 'source_url' => $original_url, + ); + } + } + } } if ( in_array( 'post', $fields, true ) ) { @@ -1797,6 +1834,29 @@ public function get_item_schema() { 'type' => 'object', 'context' => array( 'view', 'edit', 'embed' ), 'readonly' => true, + 'properties' => array( + 'original_attachment' => array( + 'description' => __( 'The attachment this image was originally created from by editing. Only present for images created by editing another image.' ), + 'type' => 'object', + 'context' => array( 'edit' ), + 'readonly' => true, + 'properties' => array( + 'attachment_id' => array( + 'description' => __( 'The ID of the original attachment.' ), + 'type' => 'integer', + 'context' => array( 'edit' ), + 'readonly' => true, + ), + 'source_url' => array( + 'description' => __( 'URL to the original attachment file.' ), + 'type' => 'string', + 'format' => 'uri', + 'context' => array( 'edit' ), + 'readonly' => true, + ), + ), + ), + ), ); $schema['properties']['post'] = array( diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 282c0f6b95e22..371d88f35481d 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -6261,4 +6261,169 @@ public function test_url_arg_rejects_unsafe_urls() { $this->assertSame( 400, $result->get_error_data()['status'] ); } } + + /** + * Edits an image and returns the ID of the attachment the edit created. + * + * @param int $attachment_id Attachment to edit. + * @return int New attachment ID. + */ + private function edit_image_and_get_new_id( $attachment_id ) { + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/edit" ); + $request->set_body_params( + array( + 'rotation' => 60, + 'src' => wp_get_attachment_image_url( $attachment_id, 'full' ), + ) + ); + + $response = rest_do_request( $request ); + $this->assertSame( 201, $response->get_status(), 'The image edit should have succeeded.' ); + + $data = $response->get_data(); + + return $data['id']; + } + + /** + * @ticket 65987 + */ + public function test_get_original_attachment_id_returns_same_id_for_an_upload() { + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + + $this->assertSame( $attachment, wp_get_original_attachment_id( $attachment ) ); + } + + /** + * @ticket 65987 + * @requires function imagejpeg + */ + public function test_edit_records_the_edited_image_as_the_original() { + wp_set_current_user( self::$superadmin_id ); + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + + $edited = $this->edit_image_and_get_new_id( $attachment ); + + $this->assertSame( $attachment, wp_get_original_attachment_id( $edited ) ); + } + + /** + * @ticket 65987 + * @requires function imagejpeg + */ + public function test_editing_an_edited_image_keeps_the_first_original() { + wp_set_current_user( self::$superadmin_id ); + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + + $edited = $this->edit_image_and_get_new_id( $attachment ); + $edited_again = $this->edit_image_and_get_new_id( $edited ); + + $this->assertSame( + $attachment, + wp_get_original_attachment_id( $edited_again ), + 'An edit of an edit should still point at the image the chain started from.' + ); + } + + /** + * @ticket 65987 + * @requires function imagejpeg + */ + public function test_edited_image_response_includes_the_original_attachment() { + wp_set_current_user( self::$superadmin_id ); + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + + $edited = $this->edit_image_and_get_new_id( $attachment ); + + $request = new WP_REST_Request( 'GET', "/wp/v2/media/{$edited}" ); + $request->set_param( 'context', 'edit' ); + $data = rest_do_request( $request )->get_data(); + + $this->assertArrayHasKey( 'original_attachment', $data['media_details'] ); + $this->assertSame( + $attachment, + $data['media_details']['original_attachment']['attachment_id'] + ); + $this->assertSame( + wp_get_attachment_url( $attachment ), + $data['media_details']['original_attachment']['source_url'] + ); + } + + /** + * @ticket 65987 + */ + public function test_uploaded_image_response_omits_the_original_attachment() { + wp_set_current_user( self::$superadmin_id ); + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + + $request = new WP_REST_Request( 'GET', "/wp/v2/media/{$attachment}" ); + $request->set_param( 'context', 'edit' ); + $data = rest_do_request( $request )->get_data(); + + $this->assertArrayNotHasKey( 'original_attachment', $data['media_details'] ); + } + + /** + * @ticket 65987 + * @requires function imagejpeg + */ + public function test_view_context_omits_the_original_attachment() { + wp_set_current_user( self::$superadmin_id ); + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + + $edited = $this->edit_image_and_get_new_id( $attachment ); + + $request = new WP_REST_Request( 'GET', "/wp/v2/media/{$edited}" ); + $request->set_param( 'context', 'view' ); + $data = rest_do_request( $request )->get_data(); + + $this->assertArrayNotHasKey( 'original_attachment', $data['media_details'] ); + } + + /** + * An attachment recorded as its own original is a broken record, not a chain, + * so nothing should be reported for it. + * + * @ticket 65987 + */ + public function test_attachment_recorded_as_its_own_original_omits_the_field() { + wp_set_current_user( self::$superadmin_id ); + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + + update_post_meta( $attachment, '_wp_attachment_original_id', $attachment ); + + $request = new WP_REST_Request( 'GET', "/wp/v2/media/{$attachment}" ); + $request->set_param( 'context', 'edit' ); + $data = rest_do_request( $request )->get_data(); + + $this->assertArrayNotHasKey( 'original_attachment', $data['media_details'] ); + } + + /** + * @ticket 65987 + * @requires function imagejpeg + */ + public function test_deleting_an_original_clears_it_from_the_images_edited_from_it() { + wp_set_current_user( self::$superadmin_id ); + + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + $edited = $this->edit_image_and_get_new_id( $attachment ); + + $unrelated = self::factory()->attachment->create_upload_object( self::$test_file ); + $unrelated_edited = $this->edit_image_and_get_new_id( $unrelated ); + + wp_delete_attachment( $attachment, true ); + + $this->assertSame( + '', + get_post_meta( $edited, '_wp_attachment_original_id', true ), + 'The record pointing at the deleted attachment should have been cleared.' + ); + $this->assertSame( + $unrelated, + wp_get_original_attachment_id( $unrelated_edited ), + 'An unrelated image should have kept its record.' + ); + } } From c677982ec3ef106700227f8f3e73f45bf1507fce Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 31 Aug 2026 12:35:19 +1000 Subject: [PATCH 2/7] Media: Move original_attachment to a top-level REST field. The field points at another attachment, and every other pointer to another entity in a media response is top level: `author`, `post`, `featured_media`. `media_details` holds the width, height, file, size and derived sizes of one image, and no references to anything else. Registering it properly also means it can be requested on its own with `_fields`, which was not possible while it was nested inside another object. Follow-up to the original commit on this branch. See #65987. --- .../class-wp-rest-attachments-controller.php | 95 ++++++++++--------- .../rest-api/rest-attachments-controller.php | 35 +++++-- 2 files changed, 76 insertions(+), 54 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 d1aa6c6bd6293..1a7e8d8d7eb33 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 @@ -1515,37 +1515,39 @@ public function prepare_item_for_response( $item, $request ) { } else { $data['media_details']['sizes'] = new stdClass(); } + } - /* - * Point an image created by editing another one back at the attachment its chain - * of edits started from, so editors can offer a way to get back to the original. - * - * Only sent in the `edit` context: this is for people editing the image, and it - * would otherwise tell visitors which images were made from which. - * - * Left out when the attachment was not created by editing another one, and when - * the original no longer has a URL, which happens if its file is missing. - */ - if ( 'edit' === $request['context'] && is_array( $data['media_details'] ) ) { - $original_id = wp_get_original_attachment_id( $post->ID ); + if ( in_array( 'post', $fields, true ) ) { + $data['post'] = ! empty( $post->post_parent ) ? (int) $post->post_parent : null; + } - if ( $original_id !== (int) $post->ID ) { - $original_url = wp_get_attachment_url( $original_id ); + /* + * Point an image created by editing another one back at the attachment its chain of + * edits started from, so editors can offer a way to get back to the original. This + * describes a relationship to another attachment rather than anything about this + * image's own file, so it sits alongside `post` rather than inside `media_details`. + * + * Only sent in the `edit` context: this is for people editing the image, and it would + * otherwise tell visitors which images were made from which. + * + * Left out when the attachment was not created by editing another one, and when the + * original no longer has a URL, which happens if its file is missing. + */ + if ( in_array( 'original_attachment', $fields, true ) && 'edit' === $request['context'] ) { + $original_id = wp_get_original_attachment_id( $post->ID ); - if ( is_string( $original_url ) && '' !== $original_url ) { - $data['media_details']['original_attachment'] = array( - 'attachment_id' => $original_id, - 'source_url' => $original_url, - ); - } + if ( $original_id !== (int) $post->ID ) { + $original_url = wp_get_attachment_url( $original_id ); + + if ( is_string( $original_url ) && '' !== $original_url ) { + $data['original_attachment'] = array( + 'attachment_id' => $original_id, + 'source_url' => $original_url, + ); } } } - if ( in_array( 'post', $fields, true ) ) { - $data['post'] = ! empty( $post->post_parent ) ? (int) $post->post_parent : null; - } - if ( in_array( 'source_url', $fields, true ) ) { $data['source_url'] = wp_get_attachment_url( $post->ID ); } @@ -1834,29 +1836,6 @@ public function get_item_schema() { 'type' => 'object', 'context' => array( 'view', 'edit', 'embed' ), 'readonly' => true, - 'properties' => array( - 'original_attachment' => array( - 'description' => __( 'The attachment this image was originally created from by editing. Only present for images created by editing another image.' ), - 'type' => 'object', - 'context' => array( 'edit' ), - 'readonly' => true, - 'properties' => array( - 'attachment_id' => array( - 'description' => __( 'The ID of the original attachment.' ), - 'type' => 'integer', - 'context' => array( 'edit' ), - 'readonly' => true, - ), - 'source_url' => array( - 'description' => __( 'URL to the original attachment file.' ), - 'type' => 'string', - 'format' => 'uri', - 'context' => array( 'edit' ), - 'readonly' => true, - ), - ), - ), - ), ); $schema['properties']['post'] = array( @@ -1865,6 +1844,28 @@ public function get_item_schema() { 'context' => array( 'view', 'edit' ), ); + $schema['properties']['original_attachment'] = array( + 'description' => __( 'The attachment this image was created from by editing. Only present for images created by editing another image.' ), + 'type' => 'object', + 'context' => array( 'edit' ), + 'readonly' => true, + 'properties' => array( + 'attachment_id' => array( + 'description' => __( 'The ID of the original attachment.' ), + 'type' => 'integer', + 'context' => array( 'edit' ), + 'readonly' => true, + ), + 'source_url' => array( + 'description' => __( 'URL to the original attachment file.' ), + 'type' => 'string', + 'format' => 'uri', + 'context' => array( 'edit' ), + 'readonly' => true, + ), + ), + ); + $schema['properties']['source_url'] = array( 'description' => __( 'URL to the original attachment file.' ), 'type' => 'string', diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 371d88f35481d..ee911e0b0d189 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -2072,13 +2072,14 @@ public function test_get_item_schema() { $response = rest_get_server()->dispatch( $request ); $data = $response->get_data(); $properties = $data['schema']['properties']; - $this->assertCount( 35, $properties ); + $this->assertCount( 36, $properties ); $this->assertArrayHasKey( 'author', $properties ); $this->assertArrayHasKey( 'alt_text', $properties ); $this->assertArrayHasKey( 'exif_orientation', $properties ); $this->assertArrayHasKey( 'image_quality', $properties ); $this->assertArrayHasKey( 'image_output_format', $properties ); $this->assertArrayHasKey( 'image_save_progressive', $properties ); + $this->assertArrayHasKey( 'original_attachment', $properties ); $this->assertArrayHasKey( 'filename', $properties ); $this->assertArrayHasKey( 'filesize', $properties ); $this->assertArrayHasKey( 'caption', $properties ); @@ -6339,14 +6340,14 @@ public function test_edited_image_response_includes_the_original_attachment() { $request->set_param( 'context', 'edit' ); $data = rest_do_request( $request )->get_data(); - $this->assertArrayHasKey( 'original_attachment', $data['media_details'] ); + $this->assertArrayHasKey( 'original_attachment', $data ); $this->assertSame( $attachment, - $data['media_details']['original_attachment']['attachment_id'] + $data['original_attachment']['attachment_id'] ); $this->assertSame( wp_get_attachment_url( $attachment ), - $data['media_details']['original_attachment']['source_url'] + $data['original_attachment']['source_url'] ); } @@ -6361,7 +6362,7 @@ public function test_uploaded_image_response_omits_the_original_attachment() { $request->set_param( 'context', 'edit' ); $data = rest_do_request( $request )->get_data(); - $this->assertArrayNotHasKey( 'original_attachment', $data['media_details'] ); + $this->assertArrayNotHasKey( 'original_attachment', $data ); } /** @@ -6378,7 +6379,27 @@ public function test_view_context_omits_the_original_attachment() { $request->set_param( 'context', 'view' ); $data = rest_do_request( $request )->get_data(); - $this->assertArrayNotHasKey( 'original_attachment', $data['media_details'] ); + $this->assertArrayNotHasKey( 'original_attachment', $data ); + } + + /** + * @ticket 65987 + * @requires function imagejpeg + */ + public function test_original_attachment_can_be_requested_on_its_own() { + wp_set_current_user( self::$superadmin_id ); + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + + $edited = $this->edit_image_and_get_new_id( $attachment ); + + $request = new WP_REST_Request( 'GET', "/wp/v2/media/{$edited}" ); + $request->set_param( 'context', 'edit' ); + $request->set_param( '_fields', 'id,original_attachment' ); + $data = rest_do_request( $request )->get_data(); + + $this->assertArrayHasKey( 'original_attachment', $data ); + $this->assertArrayNotHasKey( 'media_details', $data, 'Only the requested fields should be returned.' ); + $this->assertSame( $attachment, $data['original_attachment']['attachment_id'] ); } /** @@ -6397,7 +6418,7 @@ public function test_attachment_recorded_as_its_own_original_omits_the_field() { $request->set_param( 'context', 'edit' ); $data = rest_do_request( $request )->get_data(); - $this->assertArrayNotHasKey( 'original_attachment', $data['media_details'] ); + $this->assertArrayNotHasKey( 'original_attachment', $data ); } /** From eed59dc04690f3b650f4a2e450934a2277b7fdab Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 31 Aug 2026 12:48:14 +1000 Subject: [PATCH 3/7] Media: Cover trash and mid-chain deletion for edited image lineage. Adds tests for three cases the existing coverage left undefined. Trashing an original does not clear the record on images edited from it: `delete_attachment` only fires on permanent deletion, and keeping the record means untrashing restores the relationship intact. Editing an image whose original has been deleted starts a new chain from the image being edited, since there is no lineage left to inherit. Deleting an image from the middle of a chain leaves the images below it pointing at the start of the chain, because each one records where the chain started rather than the image directly above it. See #65987. --- .../rest-api/rest-attachments-controller.php | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index ee911e0b0d189..2699cfe59fd7e 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -6421,6 +6421,90 @@ public function test_attachment_recorded_as_its_own_original_omits_the_field() { $this->assertArrayNotHasKey( 'original_attachment', $data ); } + /** + * Trashing is not deleting. `delete_attachment` does not fire for a trashed + * attachment, and the record is deliberately left in place so that untrashing + * the original restores the relationship intact. + * + * @ticket 65987 + * @requires function imagejpeg + */ + public function test_trashing_an_original_keeps_the_record_on_the_images_edited_from_it() { + wp_set_current_user( self::$superadmin_id ); + + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + $edited = $this->edit_image_and_get_new_id( $attachment ); + + wp_trash_post( $attachment ); + + $this->assertSame( + 'trash', + get_post_status( $attachment ), + 'The original should have been trashed rather than deleted.' + ); + $this->assertSame( + $attachment, + wp_get_original_attachment_id( $edited ), + 'Trashing the original should leave the record in place.' + ); + + wp_untrash_post( $attachment ); + + $this->assertSame( + $attachment, + wp_get_original_attachment_id( $edited ), + 'Untrashing the original should leave the relationship intact.' + ); + } + + /** + * Once the original is gone its record is cleared, so a further edit has no + * lineage to inherit and starts a new chain from the image being edited. + * + * @ticket 65987 + * @requires function imagejpeg + */ + public function test_editing_again_after_the_original_is_deleted_starts_a_new_chain() { + wp_set_current_user( self::$superadmin_id ); + + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + $edited = $this->edit_image_and_get_new_id( $attachment ); + + wp_delete_attachment( $attachment, true ); + + $edited_again = $this->edit_image_and_get_new_id( $edited ); + + $this->assertSame( + $edited, + wp_get_original_attachment_id( $edited_again ), + 'The new image should point at the image it was edited from.' + ); + } + + /** + * Deleting an image from the middle of a chain does not orphan the images + * edited from it, because every image records the start of the chain rather + * than the image directly above it. + * + * @ticket 65987 + * @requires function imagejpeg + */ + public function test_deleting_a_middle_image_leaves_the_rest_of_the_chain_intact() { + wp_set_current_user( self::$superadmin_id ); + + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + $edited = $this->edit_image_and_get_new_id( $attachment ); + $edited_again = $this->edit_image_and_get_new_id( $edited ); + + wp_delete_attachment( $edited, true ); + + $this->assertSame( + $attachment, + wp_get_original_attachment_id( $edited_again ), + 'The remaining image should still point at the start of the chain.' + ); + } + /** * @ticket 65987 * @requires function imagejpeg From 70548918f1e45d4982e0e60c019ae9ac057a294d Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 31 Aug 2026 15:59:40 +1000 Subject: [PATCH 4/7] Media: Expose original_attachment as an ID with an embeddable link. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The field carried an `attachment_id` and `source_url` pair. Relationships in this API are bare IDs — `post`, `parent`, `featured_media` — so it is now just the ID of the original attachment. Clients that need the original's URL or dimensions get them from a `wp:original-attachment` link, which is embeddable in the same way as a featured image: `?_embed` hydrates the whole attachment record under `_embedded`. The link is added where the request is still in scope rather than in `prepare_links()`, which cannot see it, so the link stays in the `edit` context alongside the field. See #65987. --- .../class-wp-rest-attachments-controller.php | 46 ++++++++-------- .../rest-api/rest-attachments-controller.php | 54 ++++++++++++++++--- 2 files changed, 70 insertions(+), 30 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 1a7e8d8d7eb33..5839017793d33 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 @@ -1523,9 +1523,11 @@ public function prepare_item_for_response( $item, $request ) { /* * Point an image created by editing another one back at the attachment its chain of - * edits started from, so editors can offer a way to get back to the original. This - * describes a relationship to another attachment rather than anything about this - * image's own file, so it sits alongside `post` rather than inside `media_details`. + * edits started from, so editors can offer a way to get back to the original. Just + * the ID, like `featured_media`: this describes a relationship to another attachment + * rather than anything about this image's own file, so it sits alongside `post` + * rather than inside `media_details`. The link added below lets clients fetch the + * original's URL and dimensions with `_embed`. * * Only sent in the `edit` context: this is for people editing the image, and it would * otherwise tell visitors which images were made from which. @@ -1540,10 +1542,7 @@ public function prepare_item_for_response( $item, $request ) { $original_url = wp_get_attachment_url( $original_id ); if ( is_string( $original_url ) && '' !== $original_url ) { - $data['original_attachment'] = array( - 'attachment_id' => $original_id, - 'source_url' => $original_url, - ); + $data['original_attachment'] = $original_id; } } } @@ -1706,6 +1705,20 @@ public function prepare_item_for_response( $item, $request ) { } } + /* + * Let clients fetch the original attachment in the same request with `_embed`, + * the way `featured_media` is paired with its own link. Added here rather than in + * `prepare_links()` because that method cannot see the request, and this belongs + * in the `edit` context only, alongside the field itself. + */ + if ( isset( $data['original_attachment'] ) ) { + $response->add_link( + 'https://api.w.org/original-attachment', + rest_url( rest_get_route_for_post( $data['original_attachment'] ) ), + array( 'embeddable' => true ) + ); + } + /** * Filters an attachment returned from the REST API. * @@ -1845,25 +1858,10 @@ public function get_item_schema() { ); $schema['properties']['original_attachment'] = array( - 'description' => __( 'The attachment this image was created from by editing. Only present for images created by editing another image.' ), - 'type' => 'object', + 'description' => __( 'The ID of the attachment this image was created from by editing. Only present for images created by editing another image.' ), + 'type' => 'integer', 'context' => array( 'edit' ), 'readonly' => true, - 'properties' => array( - 'attachment_id' => array( - 'description' => __( 'The ID of the original attachment.' ), - 'type' => 'integer', - 'context' => array( 'edit' ), - 'readonly' => true, - ), - 'source_url' => array( - 'description' => __( 'URL to the original attachment file.' ), - 'type' => 'string', - 'format' => 'uri', - 'context' => array( 'edit' ), - 'readonly' => true, - ), - ), ); $schema['properties']['source_url'] = array( diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 2699cfe59fd7e..5253ec1cfaf1b 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -6341,16 +6341,58 @@ public function test_edited_image_response_includes_the_original_attachment() { $data = rest_do_request( $request )->get_data(); $this->assertArrayHasKey( 'original_attachment', $data ); + $this->assertSame( $attachment, $data['original_attachment'] ); + } + + /** + * The response carries only the ID, so the original is offered as an embeddable + * link in the same way as a featured image. + * + * @ticket 65987 + * @requires function imagejpeg + */ + public function test_original_attachment_is_embeddable() { + wp_set_current_user( self::$superadmin_id ); + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + + $edited = $this->edit_image_and_get_new_id( $attachment ); + + $request = new WP_REST_Request( 'GET', "/wp/v2/media/{$edited}" ); + $request->set_param( 'context', 'edit' ); + $response = rest_do_request( $request ); + + $links = $response->get_links(); + $this->assertArrayHasKey( 'https://api.w.org/original-attachment', $links ); + + $link = $links['https://api.w.org/original-attachment'][0]; + $this->assertStringEndsWith( '/wp/v2/media/' . $attachment, $link['href'] ); + $this->assertTrue( $link['attributes']['embeddable'] ); + + // Requesting `_embed` hydrates the original alongside the edited image. + $embedded = rest_get_server()->response_to_data( $response, true ); $this->assertSame( $attachment, - $data['original_attachment']['attachment_id'] - ); - $this->assertSame( - wp_get_attachment_url( $attachment ), - $data['original_attachment']['source_url'] + $embedded['_embedded']['wp:original-attachment'][0]['id'] ); } + /** + * @ticket 65987 + * @requires function imagejpeg + */ + public function test_view_context_omits_the_original_attachment_link() { + wp_set_current_user( self::$superadmin_id ); + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + + $edited = $this->edit_image_and_get_new_id( $attachment ); + + $request = new WP_REST_Request( 'GET', "/wp/v2/media/{$edited}" ); + $request->set_param( 'context', 'view' ); + $links = rest_do_request( $request )->get_links(); + + $this->assertArrayNotHasKey( 'https://api.w.org/original-attachment', $links ); + } + /** * @ticket 65987 */ @@ -6399,7 +6441,7 @@ public function test_original_attachment_can_be_requested_on_its_own() { $this->assertArrayHasKey( 'original_attachment', $data ); $this->assertArrayNotHasKey( 'media_details', $data, 'Only the requested fields should be returned.' ); - $this->assertSame( $attachment, $data['original_attachment']['attachment_id'] ); + $this->assertSame( $attachment, $data['original_attachment'] ); } /** From 3ee6c4be1df2b5ab6a9e0a4f2937319ff645fda8 Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 31 Aug 2026 17:20:53 +1000 Subject: [PATCH 5/7] Media: Report 0 when an image has no original attachment. The field was left out entirely for an image that was not created by editing another one. `featured_media` reports `0` for "no featured image" rather than disappearing, so this now does the same, and clients get a field of one type that is always there in the `edit` context. The stored ID is no longer checked against the original's file before being sent. Deleting an attachment already clears the ID from everything edited from it, so the check only affected originals sitting in the trash, whose files still resolve. A client following an ID that has gone stale gets no record back, which it must handle in any case. See #65987. --- .../class-wp-rest-attachments-controller.php | 27 +++++++------- .../rest-api/rest-attachments-controller.php | 35 +++++++++++++++---- 2 files changed, 41 insertions(+), 21 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 5839017793d33..c3442ee4ff2d5 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 @@ -1524,27 +1524,24 @@ public function prepare_item_for_response( $item, $request ) { /* * Point an image created by editing another one back at the attachment its chain of * edits started from, so editors can offer a way to get back to the original. Just - * the ID, like `featured_media`: this describes a relationship to another attachment - * rather than anything about this image's own file, so it sits alongside `post` - * rather than inside `media_details`. The link added below lets clients fetch the - * original's URL and dimensions with `_embed`. + * the ID, like `featured_media`, with `0` meaning the image was not created by + * editing another one: this describes a relationship to another attachment rather + * than anything about this image's own file, so it sits alongside `post` rather than + * inside `media_details`. The link added below lets clients fetch the original's URL + * and dimensions with `_embed`. * * Only sent in the `edit` context: this is for people editing the image, and it would * otherwise tell visitors which images were made from which. * - * Left out when the attachment was not created by editing another one, and when the - * original no longer has a URL, which happens if its file is missing. + * The stored ID is trusted rather than checked against the original's file, because + * deleting an attachment clears it from everything edited from it. A client that + * follows a stale ID, such as one whose original is in the trash, simply gets no + * record back. */ if ( in_array( 'original_attachment', $fields, true ) && 'edit' === $request['context'] ) { $original_id = wp_get_original_attachment_id( $post->ID ); - if ( $original_id !== (int) $post->ID ) { - $original_url = wp_get_attachment_url( $original_id ); - - if ( is_string( $original_url ) && '' !== $original_url ) { - $data['original_attachment'] = $original_id; - } - } + $data['original_attachment'] = $original_id !== (int) $post->ID ? $original_id : 0; } if ( in_array( 'source_url', $fields, true ) ) { @@ -1711,7 +1708,7 @@ public function prepare_item_for_response( $item, $request ) { * `prepare_links()` because that method cannot see the request, and this belongs * in the `edit` context only, alongside the field itself. */ - if ( isset( $data['original_attachment'] ) ) { + if ( ! empty( $data['original_attachment'] ) ) { $response->add_link( 'https://api.w.org/original-attachment', rest_url( rest_get_route_for_post( $data['original_attachment'] ) ), @@ -1858,7 +1855,7 @@ public function get_item_schema() { ); $schema['properties']['original_attachment'] = array( - 'description' => __( 'The ID of the attachment this image was created from by editing. Only present for images created by editing another image.' ), + 'description' => __( 'The ID of the attachment this image was created from by editing, or 0 if it was not created by editing another image.' ), 'type' => 'integer', 'context' => array( 'edit' ), 'readonly' => true, diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 5253ec1cfaf1b..5990f5ff77ded 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -6286,6 +6286,19 @@ private function edit_image_and_get_new_id( $attachment_id ) { return $data['id']; } + /** + * @ticket 65987 + */ + public function test_original_attachment_schema() { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/media' ); + $response = rest_get_server()->dispatch( $request ); + $schema = $response->get_data()['schema']['properties']['original_attachment']; + + $this->assertSame( 'integer', $schema['type'] ); + $this->assertSame( array( 'edit' ), $schema['context'] ); + $this->assertTrue( $schema['readonly'] ); + } + /** * @ticket 65987 */ @@ -6363,6 +6376,11 @@ public function test_original_attachment_is_embeddable() { $links = $response->get_links(); $this->assertArrayHasKey( 'https://api.w.org/original-attachment', $links ); + $this->assertCount( + 1, + $links['https://api.w.org/original-attachment'], + 'The link should be added once.' + ); $link = $links['https://api.w.org/original-attachment'][0]; $this->assertStringEndsWith( '/wp/v2/media/' . $attachment, $link['href'] ); @@ -6396,15 +6414,20 @@ public function test_view_context_omits_the_original_attachment_link() { /** * @ticket 65987 */ - public function test_uploaded_image_response_omits_the_original_attachment() { + public function test_uploaded_image_reports_no_original_attachment() { wp_set_current_user( self::$superadmin_id ); $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); $request = new WP_REST_Request( 'GET', "/wp/v2/media/{$attachment}" ); $request->set_param( 'context', 'edit' ); - $data = rest_do_request( $request )->get_data(); + $response = rest_do_request( $request ); - $this->assertArrayNotHasKey( 'original_attachment', $data ); + $this->assertSame( 0, $response->get_data()['original_attachment'] ); + $this->assertArrayNotHasKey( + 'https://api.w.org/original-attachment', + $response->get_links(), + 'An image with no original should carry no link.' + ); } /** @@ -6446,11 +6469,11 @@ public function test_original_attachment_can_be_requested_on_its_own() { /** * An attachment recorded as its own original is a broken record, not a chain, - * so nothing should be reported for it. + * so it reports no original. * * @ticket 65987 */ - public function test_attachment_recorded_as_its_own_original_omits_the_field() { + public function test_attachment_recorded_as_its_own_original_reports_none() { wp_set_current_user( self::$superadmin_id ); $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); @@ -6460,7 +6483,7 @@ public function test_attachment_recorded_as_its_own_original_omits_the_field() { $request->set_param( 'context', 'edit' ); $data = rest_do_request( $request )->get_data(); - $this->assertArrayNotHasKey( 'original_attachment', $data ); + $this->assertSame( 0, $data['original_attachment'] ); } /** From 65af1243e600154e350fd9bf0f51439dc5d52cdf Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 14 Sep 2026 16:58:12 +1000 Subject: [PATCH 6/7] Media: Correct the original attachment comment and descriptions. The field comment said `original_attachment` is limited to the `edit` context so visitors cannot tell which images were made from which. `media_details` already exposes `parent_image` in the `view` and `embed` contexts, so that was not the reason. It is limited to `edit` because only editors need it. The schema description and the `@return` of `wp_get_original_attachment_id()` said a missing original meant the image was not created by editing another one. Images edited before this change have no record either, so both now say none is recorded. See #65987. --- src/wp-includes/post.php | 4 ++-- .../class-wp-rest-attachments-controller.php | 20 ++++--------------- 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 833cecb6430f0..b75535cc50055 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -8843,8 +8843,8 @@ function wp_get_original_image_url( $attachment_id ) { * @since 7.2.0 * * @param int $attachment_id Attachment ID. - * @return int ID of the attachment the chain started from, or `$attachment_id` when the - * attachment was not created by editing another one. + * @return int ID of the attachment the chain of edits started from, or `$attachment_id` + * if none is recorded. */ function wp_get_original_attachment_id( $attachment_id ) { $original_id = (int) get_post_meta( $attachment_id, '_wp_attachment_original_id', true ); 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 c3442ee4ff2d5..89577d8883363 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 @@ -1522,21 +1522,9 @@ public function prepare_item_for_response( $item, $request ) { } /* - * Point an image created by editing another one back at the attachment its chain of - * edits started from, so editors can offer a way to get back to the original. Just - * the ID, like `featured_media`, with `0` meaning the image was not created by - * editing another one: this describes a relationship to another attachment rather - * than anything about this image's own file, so it sits alongside `post` rather than - * inside `media_details`. The link added below lets clients fetch the original's URL - * and dimensions with `_embed`. - * - * Only sent in the `edit` context: this is for people editing the image, and it would - * otherwise tell visitors which images were made from which. - * - * The stored ID is trusted rather than checked against the original's file, because - * deleting an attachment clears it from everything edited from it. A client that - * follows a stale ID, such as one whose original is in the trash, simply gets no - * record back. + * ID of the attachment this image's chain of edits started from, or 0. + * Edit context only, since only editors need it. + * Not validated: deleting an attachment clears it from images edited from it. */ if ( in_array( 'original_attachment', $fields, true ) && 'edit' === $request['context'] ) { $original_id = wp_get_original_attachment_id( $post->ID ); @@ -1855,7 +1843,7 @@ public function get_item_schema() { ); $schema['properties']['original_attachment'] = array( - 'description' => __( 'The ID of the attachment this image was created from by editing, or 0 if it was not created by editing another image.' ), + 'description' => __( 'The ID of the attachment this attachment\'s chain of edits started from, or 0 if none is recorded.' ), 'type' => 'integer', 'context' => array( 'edit' ), 'readonly' => true, From fdbe5308a5c757fa00a9bfb8a998d2543d2cb068 Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 28 Sep 2026 15:03:13 +1000 Subject: [PATCH 7/7] Media: Rename the original attachment to the edit root. `wp_get_original_attachment_id()` sat next to `wp_get_original_image_path()` and `wp_get_original_image_url()`, which describe the unscaled upload of the same attachment rather than the attachment a chain of edits started from. The names now say which one they mean: - `wp_get_original_attachment_id()` -> `wp_get_edit_root_attachment_id()` - `_wp_delete_original_attachment_id()` -> `_wp_delete_edit_root_attachment_id()` - `_wp_attachment_original_id` postmeta -> `_wp_attachment_edit_root_id` - `original_attachment` REST field -> `edit_root` - `wp:original-attachment` link relation -> `wp:edit-root` No change in behaviour. See #65987. --- src/wp-includes/default-filters.php | 2 +- src/wp-includes/post.php | 20 ++-- .../class-wp-rest-attachments-controller.php | 24 ++--- .../rest-api/rest-attachments-controller.php | 100 +++++++++--------- 4 files changed, 73 insertions(+), 73 deletions(-) diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 7d651b90f64bf..7fa12c8802a8f 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -696,7 +696,7 @@ add_action( 'customize_controls_enqueue_scripts', 'wp_plupload_default_settings' ); add_action( 'plugins_loaded', '_wp_add_additional_image_sizes', 0 ); add_filter( 'plupload_default_settings', 'wp_show_heic_upload_error' ); -add_action( 'delete_attachment', '_wp_delete_original_attachment_id' ); +add_action( 'delete_attachment', '_wp_delete_edit_root_attachment_id' ); // Client-side media processing. add_action( 'admin_init', 'wp_set_client_side_media_processing_flag' ); diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index b75535cc50055..f2806246b6999 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -8827,14 +8827,14 @@ function wp_get_original_image_url( $attachment_id ) { } /** - * Retrieves the ID of the attachment an edited image originally came from. + * Retrieves the edit root of an attachment: the attachment its chain of edits started from. * * Editing an image through the `wp/v2/media//edit` REST endpoint does not change the * image that was edited. It saves the result as a brand new attachment, so a site can end * up with a chain of attachments: an upload, a crop of it, a crop of that crop, and so on. * * Every attachment created that way stores the ID of the attachment at the top of its chain, - * so this function can find the original in one lookup no matter how long the chain is. + * so this function can find the edit root in one lookup no matter how long the chain is. * * Attachments that were uploaded rather than created by editing have no chain of their own, * and this returns the ID that was passed in. To tell the two cases apart, compare the @@ -8846,21 +8846,21 @@ function wp_get_original_image_url( $attachment_id ) { * @return int ID of the attachment the chain of edits started from, or `$attachment_id` * if none is recorded. */ -function wp_get_original_attachment_id( $attachment_id ) { - $original_id = (int) get_post_meta( $attachment_id, '_wp_attachment_original_id', true ); +function wp_get_edit_root_attachment_id( $attachment_id ) { + $edit_root_id = (int) get_post_meta( $attachment_id, '_wp_attachment_edit_root_id', true ); - return $original_id > 0 ? $original_id : (int) $attachment_id; + return $edit_root_id > 0 ? $edit_root_id : (int) $attachment_id; } /** - * Clears the recorded original attachment ID from any attachment pointing at a deleted one. + * Clears the recorded edit root ID from any attachment pointing at a deleted one. * * Without this, attachments created by editing the deleted image would keep pointing at an * ID that no longer exists, and could later point at an unrelated attachment if WordPress * reuses that ID. * * This only runs when an attachment is deleted for good. On sites where media goes to the - * trash first, attachments keep pointing at the trashed original until the trash is emptied. + * trash first, attachments keep pointing at the trashed edit root until the trash is emptied. * * @since 7.2.0 * @@ -8868,7 +8868,7 @@ function wp_get_original_attachment_id( $attachment_id ) { * * @param int $post_id Attachment ID being deleted. */ -function _wp_delete_original_attachment_id( $post_id ) { +function _wp_delete_edit_root_attachment_id( $post_id ) { $post_id = (int) $post_id; if ( $post_id <= 0 ) { @@ -8876,11 +8876,11 @@ function _wp_delete_original_attachment_id( $post_id ) { } /* - * Deletes the meta from every attachment recording this ID as its original. The meta key + * Deletes the meta from every attachment recording this ID as its edit root. The meta key * is indexed, so this only scans the rows for attachments created by editing an image, * and it avoids searching the serialized attachment metadata for the ID. */ - delete_metadata( 'post', 0, '_wp_attachment_original_id', $post_id, true ); + delete_metadata( 'post', 0, '_wp_attachment_edit_root_id', $post_id, true ); } /** 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 89577d8883363..e275a3ecf20e7 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 @@ -1357,15 +1357,15 @@ public function edit_media_item( $request ) { ); /* - * Record the attachment this chain of edits started from, so the original can be + * Record the attachment this chain of edits started from, so the edit root can be * found in one lookup from any image later in the chain. The new attachment inherits - * the original recorded on the image being edited, or that image itself when it was + * the edit root recorded on the image being edited, or that image itself when it was * uploaded rather than edited. */ update_post_meta( $new_attachment_id, - '_wp_attachment_original_id', - wp_get_original_attachment_id( $attachment_id ) + '_wp_attachment_edit_root_id', + wp_get_edit_root_attachment_id( $attachment_id ) ); /** @@ -1526,10 +1526,10 @@ public function prepare_item_for_response( $item, $request ) { * Edit context only, since only editors need it. * Not validated: deleting an attachment clears it from images edited from it. */ - if ( in_array( 'original_attachment', $fields, true ) && 'edit' === $request['context'] ) { - $original_id = wp_get_original_attachment_id( $post->ID ); + if ( in_array( 'edit_root', $fields, true ) && 'edit' === $request['context'] ) { + $edit_root_id = wp_get_edit_root_attachment_id( $post->ID ); - $data['original_attachment'] = $original_id !== (int) $post->ID ? $original_id : 0; + $data['edit_root'] = $edit_root_id !== (int) $post->ID ? $edit_root_id : 0; } if ( in_array( 'source_url', $fields, true ) ) { @@ -1691,15 +1691,15 @@ public function prepare_item_for_response( $item, $request ) { } /* - * Let clients fetch the original attachment in the same request with `_embed`, + * Let clients fetch the edit root in the same request with `_embed`, * the way `featured_media` is paired with its own link. Added here rather than in * `prepare_links()` because that method cannot see the request, and this belongs * in the `edit` context only, alongside the field itself. */ - if ( ! empty( $data['original_attachment'] ) ) { + if ( ! empty( $data['edit_root'] ) ) { $response->add_link( - 'https://api.w.org/original-attachment', - rest_url( rest_get_route_for_post( $data['original_attachment'] ) ), + 'https://api.w.org/edit-root', + rest_url( rest_get_route_for_post( $data['edit_root'] ) ), array( 'embeddable' => true ) ); } @@ -1842,7 +1842,7 @@ public function get_item_schema() { 'context' => array( 'view', 'edit' ), ); - $schema['properties']['original_attachment'] = array( + $schema['properties']['edit_root'] = array( 'description' => __( 'The ID of the attachment this attachment\'s chain of edits started from, or 0 if none is recorded.' ), 'type' => 'integer', 'context' => array( 'edit' ), diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 5990f5ff77ded..d12e63cf025bf 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -2079,7 +2079,7 @@ public function test_get_item_schema() { $this->assertArrayHasKey( 'image_quality', $properties ); $this->assertArrayHasKey( 'image_output_format', $properties ); $this->assertArrayHasKey( 'image_save_progressive', $properties ); - $this->assertArrayHasKey( 'original_attachment', $properties ); + $this->assertArrayHasKey( 'edit_root', $properties ); $this->assertArrayHasKey( 'filename', $properties ); $this->assertArrayHasKey( 'filesize', $properties ); $this->assertArrayHasKey( 'caption', $properties ); @@ -6289,10 +6289,10 @@ private function edit_image_and_get_new_id( $attachment_id ) { /** * @ticket 65987 */ - public function test_original_attachment_schema() { + public function test_edit_root_schema() { $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/media' ); $response = rest_get_server()->dispatch( $request ); - $schema = $response->get_data()['schema']['properties']['original_attachment']; + $schema = $response->get_data()['schema']['properties']['edit_root']; $this->assertSame( 'integer', $schema['type'] ); $this->assertSame( array( 'edit' ), $schema['context'] ); @@ -6302,30 +6302,30 @@ public function test_original_attachment_schema() { /** * @ticket 65987 */ - public function test_get_original_attachment_id_returns_same_id_for_an_upload() { + public function test_get_edit_root_attachment_id_returns_same_id_for_an_upload() { $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); - $this->assertSame( $attachment, wp_get_original_attachment_id( $attachment ) ); + $this->assertSame( $attachment, wp_get_edit_root_attachment_id( $attachment ) ); } /** * @ticket 65987 * @requires function imagejpeg */ - public function test_edit_records_the_edited_image_as_the_original() { + public function test_edit_records_the_edited_image_as_the_edit_root() { wp_set_current_user( self::$superadmin_id ); $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); $edited = $this->edit_image_and_get_new_id( $attachment ); - $this->assertSame( $attachment, wp_get_original_attachment_id( $edited ) ); + $this->assertSame( $attachment, wp_get_edit_root_attachment_id( $edited ) ); } /** * @ticket 65987 * @requires function imagejpeg */ - public function test_editing_an_edited_image_keeps_the_first_original() { + public function test_editing_an_edited_image_keeps_the_first_edit_root() { wp_set_current_user( self::$superadmin_id ); $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); @@ -6334,7 +6334,7 @@ public function test_editing_an_edited_image_keeps_the_first_original() { $this->assertSame( $attachment, - wp_get_original_attachment_id( $edited_again ), + wp_get_edit_root_attachment_id( $edited_again ), 'An edit of an edit should still point at the image the chain started from.' ); } @@ -6343,7 +6343,7 @@ public function test_editing_an_edited_image_keeps_the_first_original() { * @ticket 65987 * @requires function imagejpeg */ - public function test_edited_image_response_includes_the_original_attachment() { + public function test_edited_image_response_includes_the_edit_root() { wp_set_current_user( self::$superadmin_id ); $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); @@ -6353,18 +6353,18 @@ public function test_edited_image_response_includes_the_original_attachment() { $request->set_param( 'context', 'edit' ); $data = rest_do_request( $request )->get_data(); - $this->assertArrayHasKey( 'original_attachment', $data ); - $this->assertSame( $attachment, $data['original_attachment'] ); + $this->assertArrayHasKey( 'edit_root', $data ); + $this->assertSame( $attachment, $data['edit_root'] ); } /** - * The response carries only the ID, so the original is offered as an embeddable + * The response carries only the ID, so the edit root is offered as an embeddable * link in the same way as a featured image. * * @ticket 65987 * @requires function imagejpeg */ - public function test_original_attachment_is_embeddable() { + public function test_edit_root_is_embeddable() { wp_set_current_user( self::$superadmin_id ); $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); @@ -6375,22 +6375,22 @@ public function test_original_attachment_is_embeddable() { $response = rest_do_request( $request ); $links = $response->get_links(); - $this->assertArrayHasKey( 'https://api.w.org/original-attachment', $links ); + $this->assertArrayHasKey( 'https://api.w.org/edit-root', $links ); $this->assertCount( 1, - $links['https://api.w.org/original-attachment'], + $links['https://api.w.org/edit-root'], 'The link should be added once.' ); - $link = $links['https://api.w.org/original-attachment'][0]; + $link = $links['https://api.w.org/edit-root'][0]; $this->assertStringEndsWith( '/wp/v2/media/' . $attachment, $link['href'] ); $this->assertTrue( $link['attributes']['embeddable'] ); - // Requesting `_embed` hydrates the original alongside the edited image. + // Requesting `_embed` hydrates the edit root alongside the edited image. $embedded = rest_get_server()->response_to_data( $response, true ); $this->assertSame( $attachment, - $embedded['_embedded']['wp:original-attachment'][0]['id'] + $embedded['_embedded']['wp:edit-root'][0]['id'] ); } @@ -6398,7 +6398,7 @@ public function test_original_attachment_is_embeddable() { * @ticket 65987 * @requires function imagejpeg */ - public function test_view_context_omits_the_original_attachment_link() { + public function test_view_context_omits_the_edit_root_link() { wp_set_current_user( self::$superadmin_id ); $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); @@ -6408,13 +6408,13 @@ public function test_view_context_omits_the_original_attachment_link() { $request->set_param( 'context', 'view' ); $links = rest_do_request( $request )->get_links(); - $this->assertArrayNotHasKey( 'https://api.w.org/original-attachment', $links ); + $this->assertArrayNotHasKey( 'https://api.w.org/edit-root', $links ); } /** * @ticket 65987 */ - public function test_uploaded_image_reports_no_original_attachment() { + public function test_uploaded_image_reports_no_edit_root() { wp_set_current_user( self::$superadmin_id ); $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); @@ -6422,11 +6422,11 @@ public function test_uploaded_image_reports_no_original_attachment() { $request->set_param( 'context', 'edit' ); $response = rest_do_request( $request ); - $this->assertSame( 0, $response->get_data()['original_attachment'] ); + $this->assertSame( 0, $response->get_data()['edit_root'] ); $this->assertArrayNotHasKey( - 'https://api.w.org/original-attachment', + 'https://api.w.org/edit-root', $response->get_links(), - 'An image with no original should carry no link.' + 'An image with no edit root should carry no link.' ); } @@ -6434,7 +6434,7 @@ public function test_uploaded_image_reports_no_original_attachment() { * @ticket 65987 * @requires function imagejpeg */ - public function test_view_context_omits_the_original_attachment() { + public function test_view_context_omits_the_edit_root() { wp_set_current_user( self::$superadmin_id ); $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); @@ -6444,14 +6444,14 @@ public function test_view_context_omits_the_original_attachment() { $request->set_param( 'context', 'view' ); $data = rest_do_request( $request )->get_data(); - $this->assertArrayNotHasKey( 'original_attachment', $data ); + $this->assertArrayNotHasKey( 'edit_root', $data ); } /** * @ticket 65987 * @requires function imagejpeg */ - public function test_original_attachment_can_be_requested_on_its_own() { + public function test_edit_root_can_be_requested_on_its_own() { wp_set_current_user( self::$superadmin_id ); $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); @@ -6459,42 +6459,42 @@ public function test_original_attachment_can_be_requested_on_its_own() { $request = new WP_REST_Request( 'GET', "/wp/v2/media/{$edited}" ); $request->set_param( 'context', 'edit' ); - $request->set_param( '_fields', 'id,original_attachment' ); + $request->set_param( '_fields', 'id,edit_root' ); $data = rest_do_request( $request )->get_data(); - $this->assertArrayHasKey( 'original_attachment', $data ); + $this->assertArrayHasKey( 'edit_root', $data ); $this->assertArrayNotHasKey( 'media_details', $data, 'Only the requested fields should be returned.' ); - $this->assertSame( $attachment, $data['original_attachment'] ); + $this->assertSame( $attachment, $data['edit_root'] ); } /** - * An attachment recorded as its own original is a broken record, not a chain, - * so it reports no original. + * An attachment recorded as its own edit root is a broken record, not a chain, + * so it reports no edit root. * * @ticket 65987 */ - public function test_attachment_recorded_as_its_own_original_reports_none() { + public function test_attachment_recorded_as_its_own_edit_root_reports_none() { wp_set_current_user( self::$superadmin_id ); $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); - update_post_meta( $attachment, '_wp_attachment_original_id', $attachment ); + update_post_meta( $attachment, '_wp_attachment_edit_root_id', $attachment ); $request = new WP_REST_Request( 'GET', "/wp/v2/media/{$attachment}" ); $request->set_param( 'context', 'edit' ); $data = rest_do_request( $request )->get_data(); - $this->assertSame( 0, $data['original_attachment'] ); + $this->assertSame( 0, $data['edit_root'] ); } /** * Trashing is not deleting. `delete_attachment` does not fire for a trashed * attachment, and the record is deliberately left in place so that untrashing - * the original restores the relationship intact. + * the edit root restores the relationship intact. * * @ticket 65987 * @requires function imagejpeg */ - public function test_trashing_an_original_keeps_the_record_on_the_images_edited_from_it() { + public function test_trashing_an_edit_root_keeps_the_record_on_the_images_edited_from_it() { wp_set_current_user( self::$superadmin_id ); $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); @@ -6505,31 +6505,31 @@ public function test_trashing_an_original_keeps_the_record_on_the_images_edited_ $this->assertSame( 'trash', get_post_status( $attachment ), - 'The original should have been trashed rather than deleted.' + 'The edit root should have been trashed rather than deleted.' ); $this->assertSame( $attachment, - wp_get_original_attachment_id( $edited ), - 'Trashing the original should leave the record in place.' + wp_get_edit_root_attachment_id( $edited ), + 'Trashing the edit root should leave the record in place.' ); wp_untrash_post( $attachment ); $this->assertSame( $attachment, - wp_get_original_attachment_id( $edited ), - 'Untrashing the original should leave the relationship intact.' + wp_get_edit_root_attachment_id( $edited ), + 'Untrashing the edit root should leave the relationship intact.' ); } /** - * Once the original is gone its record is cleared, so a further edit has no + * Once the edit root is gone its record is cleared, so a further edit has no * lineage to inherit and starts a new chain from the image being edited. * * @ticket 65987 * @requires function imagejpeg */ - public function test_editing_again_after_the_original_is_deleted_starts_a_new_chain() { + public function test_editing_again_after_the_edit_root_is_deleted_starts_a_new_chain() { wp_set_current_user( self::$superadmin_id ); $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); @@ -6541,7 +6541,7 @@ public function test_editing_again_after_the_original_is_deleted_starts_a_new_ch $this->assertSame( $edited, - wp_get_original_attachment_id( $edited_again ), + wp_get_edit_root_attachment_id( $edited_again ), 'The new image should point at the image it was edited from.' ); } @@ -6565,7 +6565,7 @@ public function test_deleting_a_middle_image_leaves_the_rest_of_the_chain_intact $this->assertSame( $attachment, - wp_get_original_attachment_id( $edited_again ), + wp_get_edit_root_attachment_id( $edited_again ), 'The remaining image should still point at the start of the chain.' ); } @@ -6574,7 +6574,7 @@ public function test_deleting_a_middle_image_leaves_the_rest_of_the_chain_intact * @ticket 65987 * @requires function imagejpeg */ - public function test_deleting_an_original_clears_it_from_the_images_edited_from_it() { + public function test_deleting_an_edit_root_clears_it_from_the_images_edited_from_it() { wp_set_current_user( self::$superadmin_id ); $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); @@ -6587,12 +6587,12 @@ public function test_deleting_an_original_clears_it_from_the_images_edited_from_ $this->assertSame( '', - get_post_meta( $edited, '_wp_attachment_original_id', true ), + get_post_meta( $edited, '_wp_attachment_edit_root_id', true ), 'The record pointing at the deleted attachment should have been cleared.' ); $this->assertSame( $unrelated, - wp_get_original_attachment_id( $unrelated_edited ), + wp_get_edit_root_attachment_id( $unrelated_edited ), 'An unrelated image should have kept its record.' ); }