Skip to content
Open
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 composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"media fix-orientation",
"media import",
"media regenerate",
"media replace",
"media image-size"
]
},
Expand Down
129 changes: 129 additions & 0 deletions features/media-replace.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
Feature: Replace WordPress attachment files

Background:
Given a WP install

Scenario: Replace an attachment file with a local file
Given download:
| path | url |
| {CACHE_DIR}/large-image.jpg | http://wp-cli.org/behat-data/large-image.jpg |
| {CACHE_DIR}/canola.jpg | http://wp-cli.org/behat-data/canola.jpg |
And I run `wp option update uploads_use_yearmonth_folders 0`

When I run `wp media import {CACHE_DIR}/large-image.jpg --porcelain`
Then save STDOUT as {ATTACHMENT_ID}

When I run `wp media replace {ATTACHMENT_ID} {CACHE_DIR}/canola.jpg`
Then STDOUT should contain:
"""
Replaced file for attachment ID {ATTACHMENT_ID}
"""
And STDOUT should contain:
"""
Success: Replaced 1 of 1 attachments.
"""

Scenario: Replace an attachment file from a URL
Given download:
| path | url |
| {CACHE_DIR}/large-image.jpg | http://wp-cli.org/behat-data/large-image.jpg |
And I run `wp option update uploads_use_yearmonth_folders 0`

When I run `wp media import {CACHE_DIR}/large-image.jpg --porcelain`
Then save STDOUT as {ATTACHMENT_ID}

When I run `wp media replace {ATTACHMENT_ID} 'http://wp-cli.org/behat-data/canola.jpg'`
Then STDOUT should contain:
"""
Replaced file for attachment ID {ATTACHMENT_ID}
"""
And STDOUT should contain:
"""
Success: Replaced 1 of 1 attachments.
"""

Scenario: Replace an attachment file and output only the attachment ID in porcelain mode
Given download:
| path | url |
| {CACHE_DIR}/large-image.jpg | http://wp-cli.org/behat-data/large-image.jpg |
| {CACHE_DIR}/canola.jpg | http://wp-cli.org/behat-data/canola.jpg |
And I run `wp option update uploads_use_yearmonth_folders 0`

When I run `wp media import {CACHE_DIR}/large-image.jpg --porcelain`
Then save STDOUT as {ATTACHMENT_ID}

When I run `wp media replace {ATTACHMENT_ID} {CACHE_DIR}/canola.jpg --porcelain`
Then STDOUT should be:
"""
{ATTACHMENT_ID}
"""

Scenario: Preserve attachment metadata after replacing the file
Given download:
| path | url |
| {CACHE_DIR}/large-image.jpg | http://wp-cli.org/behat-data/large-image.jpg |
| {CACHE_DIR}/canola.jpg | http://wp-cli.org/behat-data/canola.jpg |
And I run `wp option update uploads_use_yearmonth_folders 0`

When I run `wp media import {CACHE_DIR}/large-image.jpg --title="My Image Title" --porcelain`
Then save STDOUT as {ATTACHMENT_ID}

When I run `wp media replace {ATTACHMENT_ID} {CACHE_DIR}/canola.jpg`
Then STDOUT should contain:
"""
Success: Replaced 1 of 1 attachments.
"""

When I run `wp post get {ATTACHMENT_ID} --field=post_title`
Then STDOUT should be:
"""
My Image Title
"""

Scenario: Error when replacing with a non-existent local file
Given download:
| path | url |
| {CACHE_DIR}/large-image.jpg | http://wp-cli.org/behat-data/large-image.jpg |

When I run `wp media import {CACHE_DIR}/large-image.jpg --porcelain`
Then save STDOUT as {ATTACHMENT_ID}

When I try `wp media replace {ATTACHMENT_ID} /tmp/nonexistent-file.jpg`
Then STDERR should contain:
"""
Error: Unable to replace attachment
"""
And STDERR should contain:
"""
File doesn't exist.
"""
And the return code should be 1

Scenario: Error when replacing with an invalid attachment ID
When I try `wp media replace 999999 /tmp/fake.jpg`
Then STDERR should contain:
"""
Error: Invalid attachment ID 999999.
"""
And the return code should be 1

Scenario: Skip deletion of old thumbnails when --skip-delete flag is used
Given download:
| path | url |
| {CACHE_DIR}/large-image.jpg | http://wp-cli.org/behat-data/large-image.jpg |
| {CACHE_DIR}/canola.jpg | http://wp-cli.org/behat-data/canola.jpg |
And I run `wp option update uploads_use_yearmonth_folders 0`

When I run `wp media import {CACHE_DIR}/large-image.jpg --porcelain`
Then save STDOUT as {ATTACHMENT_ID}

When I run `wp post meta get {ATTACHMENT_ID} _wp_attached_file`
Then save STDOUT as {OLD_FILE}

When I run `wp media replace {ATTACHMENT_ID} {CACHE_DIR}/canola.jpg --skip-delete`
Then STDOUT should contain:
"""
Success: Replaced 1 of 1 attachments.
"""

And the wp-content/uploads/{OLD_FILE} file should exist
164 changes: 163 additions & 1 deletion src/Media_Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
use WP_CLI\Utils;

/**
* Imports files as attachments, regenerates thumbnails, or lists registered image sizes.
* Imports files as attachments, regenerates thumbnails, replaces existing attachment files, or lists registered image sizes.
*
* ## EXAMPLES
*
Expand Down Expand Up @@ -548,6 +548,168 @@ public function import( $args, $assoc_args = array() ) {
}
}

/**
* Replaces the file for an existing attachment while preserving its identity.
*
* ## OPTIONS
*
* <attachment-id>
* : ID of the attachment whose file is to be replaced.
*
* <file>
* : Path to the replacement file. Supports local paths and URLs.
*
* [--skip-delete]
* : Skip deletion of old thumbnail files after replacement.
*
* [--porcelain]
* : Output just the attachment ID after replacement.
*
* ## EXAMPLES
*
* # Replace an attachment file with a local file.
* $ wp media replace 123 ~/new-image.jpg
* Replaced file for attachment ID 123 with '/home/user/new-image.jpg'.
* Success: Replaced 1 of 1 images.
*
* # Replace an attachment file with a file from a URL.
* $ wp media replace 123 'http://example.com/image.jpg'
* Replaced file for attachment ID 123 with 'http://example.com/image.jpg'.
* Success: Replaced 1 of 1 images.
*
* # Replace and output just the attachment ID.
* $ wp media replace 123 ~/new-image.jpg --porcelain
* 123
*
* @param string[] $args Positional arguments.
* @param array{'skip-delete'?: bool, porcelain?: bool} $assoc_args Associative arguments.
* @return void
*/
public function replace( $args, $assoc_args = array() ) {
$attachment_id = (int) $args[0];
$file = $args[1];

// Validate attachment exists.
$attachment = get_post( $attachment_id );
if ( ! $attachment || 'attachment' !== $attachment->post_type ) {
WP_CLI::error( "Invalid attachment ID {$attachment_id}." );
}

// Handle remote vs local file (same pattern as import).
// phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- parse_url will only be used in absence of wp_parse_url.
$is_file_remote = function_exists( 'wp_parse_url' ) ? wp_parse_url( $file, PHP_URL_HOST ) : parse_url( $file, PHP_URL_HOST );
$orig_filename = $file;

if ( empty( $is_file_remote ) ) {
if ( ! file_exists( $file ) ) {
WP_CLI::error( "Unable to replace attachment {$attachment_id} with file '{$file}'. Reason: File doesn't exist." );
}
$tempfile = $this->make_copy( $file );
$name = Utils\basename( $file );
} else {
$tempfile = download_url( $file );
if ( is_wp_error( $tempfile ) ) {
WP_CLI::error(
sprintf(
"Unable to replace attachment %d with file '%s'. Reason: %s",
$attachment_id,
$file,
implode( ', ', $tempfile->get_error_messages() )
)
);
}
$name = (string) strtok( Utils\basename( $file ), '?' );
}

// Get old metadata before replacement for cleanup.
$old_fullsizepath = $this->get_attached_file( $attachment_id );
$old_metadata = wp_get_attachment_metadata( $attachment_id );

// Move the temp file into the uploads directory.
$file_array = array(
'name' => $name,
'tmp_name' => $tempfile,
);

$uploaded = wp_handle_sideload( $file_array, array( 'test_form' => false ) );

if ( isset( $uploaded['error'] ) ) {
if ( isset( $tempfile ) && is_string( $tempfile ) && file_exists( $tempfile ) ) {
unlink( $tempfile );
}
WP_CLI::error( "Failed to process file '{$orig_filename}': {$uploaded['error']}" );
}

$new_file_path = $uploaded['file'];
$new_mime_type = $uploaded['type'];

// Delete old thumbnail files unless asked to skip.
if ( ! Utils\get_flag_value( $assoc_args, 'skip-delete' )
&& false !== $old_fullsizepath
&& is_array( $old_metadata )
) {
$this->remove_old_images( $old_metadata, $old_fullsizepath, array() );

// Also delete the previous full-size file itself to avoid leaving an orphan.
if ( $old_fullsizepath !== $new_file_path && file_exists( $old_fullsizepath ) ) {
@unlink( $old_fullsizepath );
}

// For big-image scaling (WP 5.3+), delete the original image if present in metadata.
$original_image = isset( $old_metadata['original_image'] ) ? (string) $old_metadata['original_image'] : '';
if ( '' !== $original_image && ! empty( $old_metadata['file'] ) ) {
$uploads = wp_get_upload_dir();
if ( ! empty( $uploads['basedir'] ) ) {
$dirname = dirname( $old_metadata['file'] );
$original_image_rel = ( '.' === $dirname || '/' === $dirname ) ? $original_image : $dirname . '/' . $original_image;
$original_image_abspath = $uploads['basedir'] . '/' . $original_image_rel;
if ( $original_image_abspath !== $new_file_path && file_exists( $original_image_abspath ) ) {
@unlink( $original_image_abspath );
}
}
}
}

// Update the attachment's MIME type.
$updated = wp_update_post(
array(
'ID' => $attachment_id,
'post_mime_type' => $new_mime_type,
),
true
);
if ( is_wp_error( $updated ) ) {
WP_CLI::warning(
sprintf( 'Failed to update MIME type for attachment %d: %s', $attachment_id, $updated->get_error_message() )
);
}

// Update the attached file path.
update_attached_file( $attachment_id, $new_file_path );

// Generate and update new attachment metadata.
$new_metadata = wp_generate_attachment_metadata( $attachment_id, $new_file_path );
if ( is_array( $new_metadata ) && ! empty( $new_metadata ) ) {
wp_update_attachment_metadata( $attachment_id, $new_metadata );
} else {
WP_CLI::warning(
sprintf(
'Failed to generate new attachment metadata for attachment ID %d. Existing metadata has been preserved.',
$attachment_id
)
);
}

if ( Utils\get_flag_value( $assoc_args, 'porcelain' ) ) {
WP_CLI::line( (string) $attachment_id );
} else {
WP_CLI::log(
sprintf( "Replaced file for attachment ID %d with '%s'.", $attachment_id, $orig_filename )
);
Utils\report_batch_operation_results( 'attachment', 'replace', 1, 1, 0 );
}
}

/**
* Lists image sizes registered with WordPress.
*
Expand Down
Loading