Commit c8b6922302 for wordpress.org
commit c8b69223022dc03771d80cbeb02ecb14b2452377
Author: peterwilsoncc <peterwilsoncc@git.wordpress.org>
Date: Tue Oct 6 04:04:00 2026 +0000
Media: Track the original attachment for edited images.
The block editor's image editor creates an entirely new attachment with no stable pointer back to the image the lineage started from.
This change records the attachment an edit chain started from in `_wp_attachment_edit_root_id` postmeta, reads it back with `wp_get_edit_root_attachment_id()`, and exposes it as a top-level `edit_root` field on the attachment REST response:
* the ID of the edit root, or,
* `0` when the image was not created by editing another one.
The field is included in the REST API's `edit` context only, alongside an embeddable `wp:edit-root` link so clients can hydrate the edit root with `_embed`.
The link is only added when the edit root is published or the user can read it. The `edit_root` field still reports the recorded ID.
Developed in https://github.com/WordPress/wordpress-develop/pull/13303
Props ramonopoly, andrewserong.
Fixes #65987.
Built from https://develop.svn.wordpress.org/trunk@64118
git-svn-id: http://core.svn.wordpress.org/trunk@63274 1a063a9b-81f0-0310-95a4-ce76da25c4cd
diff --git a/wp-includes/default-filters.php b/wp-includes/default-filters.php
index 025a371781..7fa12c8802 100644
--- a/wp-includes/default-filters.php
+++ b/wp-includes/default-filters.php
@@ -696,6 +696,7 @@ add_action( 'wp_playlist_scripts', 'wp_playlist_scripts' );
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_edit_root_attachment_id' );
// Client-side media processing.
add_action( 'admin_init', 'wp_set_client_side_media_processing_flag' );
diff --git a/wp-includes/post.php b/wp-includes/post.php
index 5edc8a50f0..a651afd41b 100644
--- a/wp-includes/post.php
+++ b/wp-includes/post.php
@@ -8842,6 +8842,66 @@ function wp_get_original_image_url( $attachment_id ) {
return apply_filters( 'wp_get_original_image_url', $original_image_url, $attachment_id );
}
+/**
+ * Retrieves the edit root of an attachment: the attachment its chain of edits started from.
+ *
+ * Editing an image through the `wp/v2/media/<id>/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 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 0 for them.
+ *
+ * @since 7.2.0
+ *
+ * @param int $attachment_id Attachment ID.
+ * @return int ID of the attachment the chain of edits started from, or 0 when none is recorded.
+ */
+function wp_get_edit_root_attachment_id( $attachment_id ) {
+ $edit_root_id = (int) get_post_meta( $attachment_id, '_wp_attachment_edit_root_id', true );
+
+ // An attachment recorded as its own edit root is a broken record rather than a chain.
+ if ( $edit_root_id <= 0 || $edit_root_id === (int) $attachment_id ) {
+ return 0;
+ }
+
+ return $edit_root_id;
+}
+
+/**
+ * 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 edit root until the trash is emptied.
+ *
+ * @since 7.2.0
+ *
+ * @access private
+ *
+ * @param int $post_id Attachment ID being deleted.
+ */
+function _wp_delete_edit_root_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 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_edit_root_id', $post_id, true );
+}
+
/**
* Filters callback which sets the status of an untrashed post to its previous status.
*
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php
index 9807ac9cf1..f0f1d0e49d 100644
--- a/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php
@@ -1356,6 +1356,20 @@ class WP_REST_Attachments_Controller extends WP_REST_Posts_Controller {
'file' => _wp_relative_upload_path( $image_file ),
);
+ /*
+ * 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 edit root recorded on the image being edited, or that image itself when it was
+ * uploaded rather than edited.
+ */
+ $edit_root_id = wp_get_edit_root_attachment_id( $attachment_id );
+
+ if ( ! $edit_root_id ) {
+ $edit_root_id = (int) $attachment_id;
+ }
+
+ update_post_meta( $new_attachment_id, '_wp_attachment_edit_root_id', $edit_root_id );
+
/**
* Filters the meta data for the new image created by editing an existing image.
*
@@ -1509,6 +1523,15 @@ class WP_REST_Attachments_Controller extends WP_REST_Posts_Controller {
$data['post'] = ! empty( $post->post_parent ) ? (int) $post->post_parent : null;
}
+ /*
+ * 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( 'edit_root', $fields, true ) && 'edit' === $request['context'] ) {
+ $data['edit_root'] = wp_get_edit_root_attachment_id( $post->ID );
+ }
+
if ( in_array( 'source_url', $fields, true ) ) {
$data['source_url'] = wp_get_attachment_url( $post->ID );
}
@@ -1667,6 +1690,31 @@ class WP_REST_Attachments_Controller extends WP_REST_Posts_Controller {
}
}
+ /*
+ * Embeddable link to the edit root, like `featured_media`. Added here rather than
+ * in `prepare_links()`, which cannot see the request, and gated like the parent
+ * controller's own links so a `_fields` request is not handed a stray `_links`.
+ * Like `featured_media`, the link is skipped when the edit root no longer exists
+ * or the user cannot read it, although the `edit_root` field still reports the ID.
+ */
+ if (
+ 'edit' === $request['context'] &&
+ ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) )
+ ) {
+ $edit_root_id = wp_get_edit_root_attachment_id( $post->ID );
+
+ if (
+ $edit_root_id &&
+ ( 'publish' === get_post_status( $edit_root_id ) || current_user_can( 'read_post', $edit_root_id ) )
+ ) {
+ $response->add_link(
+ 'https://api.w.org/edit-root',
+ rest_url( rest_get_route_for_post( $edit_root_id ) ),
+ array( 'embeddable' => true )
+ );
+ }
+ }
+
/**
* Filters an attachment returned from the REST API.
*
@@ -1805,6 +1853,13 @@ class WP_REST_Attachments_Controller extends WP_REST_Posts_Controller {
'context' => array( 'view', 'edit' ),
);
+ $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' ),
+ 'readonly' => true,
+ );
+
$schema['properties']['source_url'] = array(
'description' => __( 'URL to the original attachment file.' ),
'type' => 'string',
diff --git a/wp-includes/version.php b/wp-includes/version.php
index efe0949ae7..f51d75675e 100644
--- a/wp-includes/version.php
+++ b/wp-includes/version.php
@@ -16,7 +16,7 @@
*
* @global string $wp_version
*/
-$wp_version = '7.2-alpha-64117';
+$wp_version = '7.2-alpha-64118';
/**
* Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.