diff --git a/composer.json b/composer.json index c5ed11aa..b15c80a2 100644 --- a/composer.json +++ b/composer.json @@ -40,6 +40,7 @@ "media import", "media prune", "media regenerate", + "media replace", "media image-size" ] }, diff --git a/features/media-replace.feature b/features/media-replace.feature new file mode 100644 index 00000000..06981ec6 --- /dev/null +++ b/features/media-replace.feature @@ -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 diff --git a/src/Media_Command.php b/src/Media_Command.php index 07edc531..c1215ec9 100644 --- a/src/Media_Command.php +++ b/src/Media_Command.php @@ -4,7 +4,7 @@ use WP_CLI\Path; /** - * 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 * @@ -775,6 +775,168 @@ public function import( $args, $assoc_args = array() ) { } } + /** + * Replaces the file for an existing attachment while preserving its identity. + * + * ## OPTIONS + * + * + * : ID of the attachment whose file is to be replaced. + * + * + * : 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 = Path::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( Path::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. *