Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/wp-includes/default-filters.php
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,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' );
Expand Down
57 changes: 57 additions & 0 deletions src/wp-includes/post.php
Original file line number Diff line number Diff line change
Expand Up @@ -8751,6 +8751,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/<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 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 );

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

true is for $delete_all

We want this because, when the original is deleted we want to clear all descendants.

See: https://developer.wordpress.org/reference/functions/delete_metadata/

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A note on deletion paths:

  • wp_delete_post() delegates to wp_delete_attachment() for attachments, so every Core route should reach delete_attachment and clear the record.
  • From what I've traced it has the same "reachability" as Core's _thumbnail_id cleanup, which uses the identical delete_metadata()
  • doesn't fire on trash, which I think is the current pattern (the attachment still exists )

Plugins could still short circuit deletion and do it themselves via any filter, e.g., pre_delete_attachment. That means the clean up might not happen. This isn't Core's to fix, but we should mention this in the dev note for 7.2.

}

/**
* Filters callback which sets the status of an untrashed post to its previous status.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -1509,6 +1521,29 @@ public function prepare_item_for_response( $item, $request ) {
$data['post'] = ! empty( $post->post_parent ) ? (int) $post->post_parent : null;
}

/*
* 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.
*/
if ( in_array( 'original_attachment', $fields, true ) && 'edit' === $request['context'] ) {
$original_id = wp_get_original_attachment_id( $post->ID );

$data['original_attachment'] = $original_id !== (int) $post->ID ? $original_id : 0;
}

if ( in_array( 'source_url', $fields, true ) ) {
$data['source_url'] = wp_get_attachment_url( $post->ID );
}
Expand Down Expand Up @@ -1667,6 +1702,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 ( ! empty( $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.
*
Expand Down Expand Up @@ -1805,6 +1854,13 @@ public function get_item_schema() {
'context' => array( 'view', 'edit' ),
);

$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.' ),
'type' => 'integer',
'context' => array( 'edit' ),
'readonly' => true,
);

$schema['properties']['source_url'] = array(
'description' => __( 'URL to the original attachment file.' ),
'type' => 'string',
Expand Down
Loading
Loading