Skip to content

Commit d33ec96

Browse files
CopilotswissspidyCopilot
authored
Add wp media replace subcommand (#240)
Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com> Co-authored-by: Pascal Birchler <pascalb@google.com>
1 parent 05b93f4 commit d33ec96

3 files changed

Lines changed: 293 additions & 1 deletion

File tree

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"media import",
4141
"media prune",
4242
"media regenerate",
43+
"media replace",
4344
"media image-size"
4445
]
4546
},

features/media-replace.feature

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
Feature: Replace WordPress attachment files
2+
3+
Background:
4+
Given a WP install
5+
6+
Scenario: Replace an attachment file with a local file
7+
Given download:
8+
| path | url |
9+
| {CACHE_DIR}/large-image.jpg | http://wp-cli.org/behat-data/large-image.jpg |
10+
| {CACHE_DIR}/canola.jpg | http://wp-cli.org/behat-data/canola.jpg |
11+
And I run `wp option update uploads_use_yearmonth_folders 0`
12+
13+
When I run `wp media import {CACHE_DIR}/large-image.jpg --porcelain`
14+
Then save STDOUT as {ATTACHMENT_ID}
15+
16+
When I run `wp media replace {ATTACHMENT_ID} {CACHE_DIR}/canola.jpg`
17+
Then STDOUT should contain:
18+
"""
19+
Replaced file for attachment ID {ATTACHMENT_ID}
20+
"""
21+
And STDOUT should contain:
22+
"""
23+
Success: Replaced 1 of 1 attachments.
24+
"""
25+
26+
Scenario: Replace an attachment file from a URL
27+
Given download:
28+
| path | url |
29+
| {CACHE_DIR}/large-image.jpg | http://wp-cli.org/behat-data/large-image.jpg |
30+
And I run `wp option update uploads_use_yearmonth_folders 0`
31+
32+
When I run `wp media import {CACHE_DIR}/large-image.jpg --porcelain`
33+
Then save STDOUT as {ATTACHMENT_ID}
34+
35+
When I run `wp media replace {ATTACHMENT_ID} 'http://wp-cli.org/behat-data/canola.jpg'`
36+
Then STDOUT should contain:
37+
"""
38+
Replaced file for attachment ID {ATTACHMENT_ID}
39+
"""
40+
And STDOUT should contain:
41+
"""
42+
Success: Replaced 1 of 1 attachments.
43+
"""
44+
45+
Scenario: Replace an attachment file and output only the attachment ID in porcelain mode
46+
Given download:
47+
| path | url |
48+
| {CACHE_DIR}/large-image.jpg | http://wp-cli.org/behat-data/large-image.jpg |
49+
| {CACHE_DIR}/canola.jpg | http://wp-cli.org/behat-data/canola.jpg |
50+
And I run `wp option update uploads_use_yearmonth_folders 0`
51+
52+
When I run `wp media import {CACHE_DIR}/large-image.jpg --porcelain`
53+
Then save STDOUT as {ATTACHMENT_ID}
54+
55+
When I run `wp media replace {ATTACHMENT_ID} {CACHE_DIR}/canola.jpg --porcelain`
56+
Then STDOUT should be:
57+
"""
58+
{ATTACHMENT_ID}
59+
"""
60+
61+
Scenario: Preserve attachment metadata after replacing the file
62+
Given download:
63+
| path | url |
64+
| {CACHE_DIR}/large-image.jpg | http://wp-cli.org/behat-data/large-image.jpg |
65+
| {CACHE_DIR}/canola.jpg | http://wp-cli.org/behat-data/canola.jpg |
66+
And I run `wp option update uploads_use_yearmonth_folders 0`
67+
68+
When I run `wp media import {CACHE_DIR}/large-image.jpg --title="My Image Title" --porcelain`
69+
Then save STDOUT as {ATTACHMENT_ID}
70+
71+
When I run `wp media replace {ATTACHMENT_ID} {CACHE_DIR}/canola.jpg`
72+
Then STDOUT should contain:
73+
"""
74+
Success: Replaced 1 of 1 attachments.
75+
"""
76+
77+
When I run `wp post get {ATTACHMENT_ID} --field=post_title`
78+
Then STDOUT should be:
79+
"""
80+
My Image Title
81+
"""
82+
83+
Scenario: Error when replacing with a non-existent local file
84+
Given download:
85+
| path | url |
86+
| {CACHE_DIR}/large-image.jpg | http://wp-cli.org/behat-data/large-image.jpg |
87+
88+
When I run `wp media import {CACHE_DIR}/large-image.jpg --porcelain`
89+
Then save STDOUT as {ATTACHMENT_ID}
90+
91+
When I try `wp media replace {ATTACHMENT_ID} /tmp/nonexistent-file.jpg`
92+
Then STDERR should contain:
93+
"""
94+
Error: Unable to replace attachment
95+
"""
96+
And STDERR should contain:
97+
"""
98+
File doesn't exist.
99+
"""
100+
And the return code should be 1
101+
102+
Scenario: Error when replacing with an invalid attachment ID
103+
When I try `wp media replace 999999 /tmp/fake.jpg`
104+
Then STDERR should contain:
105+
"""
106+
Error: Invalid attachment ID 999999.
107+
"""
108+
And the return code should be 1
109+
110+
Scenario: Skip deletion of old thumbnails when --skip-delete flag is used
111+
Given download:
112+
| path | url |
113+
| {CACHE_DIR}/large-image.jpg | http://wp-cli.org/behat-data/large-image.jpg |
114+
| {CACHE_DIR}/canola.jpg | http://wp-cli.org/behat-data/canola.jpg |
115+
And I run `wp option update uploads_use_yearmonth_folders 0`
116+
117+
When I run `wp media import {CACHE_DIR}/large-image.jpg --porcelain`
118+
Then save STDOUT as {ATTACHMENT_ID}
119+
120+
When I run `wp post meta get {ATTACHMENT_ID} _wp_attached_file`
121+
Then save STDOUT as {OLD_FILE}
122+
123+
When I run `wp media replace {ATTACHMENT_ID} {CACHE_DIR}/canola.jpg --skip-delete`
124+
Then STDOUT should contain:
125+
"""
126+
Success: Replaced 1 of 1 attachments.
127+
"""
128+
129+
And the wp-content/uploads/{OLD_FILE} file should exist

src/Media_Command.php

Lines changed: 163 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
use WP_CLI\Path;
55

66
/**
7-
* Imports files as attachments, regenerates thumbnails, or lists registered image sizes.
7+
* Imports files as attachments, regenerates thumbnails, replaces existing attachment files, or lists registered image sizes.
88
*
99
* ## EXAMPLES
1010
*
@@ -775,6 +775,168 @@ public function import( $args, $assoc_args = array() ) {
775775
}
776776
}
777777

778+
/**
779+
* Replaces the file for an existing attachment while preserving its identity.
780+
*
781+
* ## OPTIONS
782+
*
783+
* <attachment-id>
784+
* : ID of the attachment whose file is to be replaced.
785+
*
786+
* <file>
787+
* : Path to the replacement file. Supports local paths and URLs.
788+
*
789+
* [--skip-delete]
790+
* : Skip deletion of old thumbnail files after replacement.
791+
*
792+
* [--porcelain]
793+
* : Output just the attachment ID after replacement.
794+
*
795+
* ## EXAMPLES
796+
*
797+
* # Replace an attachment file with a local file.
798+
* $ wp media replace 123 ~/new-image.jpg
799+
* Replaced file for attachment ID 123 with '/home/user/new-image.jpg'.
800+
* Success: Replaced 1 of 1 images.
801+
*
802+
* # Replace an attachment file with a file from a URL.
803+
* $ wp media replace 123 'http://example.com/image.jpg'
804+
* Replaced file for attachment ID 123 with 'http://example.com/image.jpg'.
805+
* Success: Replaced 1 of 1 images.
806+
*
807+
* # Replace and output just the attachment ID.
808+
* $ wp media replace 123 ~/new-image.jpg --porcelain
809+
* 123
810+
*
811+
* @param string[] $args Positional arguments.
812+
* @param array{'skip-delete'?: bool, porcelain?: bool} $assoc_args Associative arguments.
813+
* @return void
814+
*/
815+
public function replace( $args, $assoc_args = array() ) {
816+
$attachment_id = (int) $args[0];
817+
$file = $args[1];
818+
819+
// Validate attachment exists.
820+
$attachment = get_post( $attachment_id );
821+
if ( ! $attachment || 'attachment' !== $attachment->post_type ) {
822+
WP_CLI::error( "Invalid attachment ID {$attachment_id}." );
823+
}
824+
825+
// Handle remote vs local file (same pattern as import).
826+
// phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- parse_url will only be used in absence of wp_parse_url.
827+
$is_file_remote = function_exists( 'wp_parse_url' ) ? wp_parse_url( $file, PHP_URL_HOST ) : parse_url( $file, PHP_URL_HOST );
828+
$orig_filename = $file;
829+
830+
if ( empty( $is_file_remote ) ) {
831+
if ( ! file_exists( $file ) ) {
832+
WP_CLI::error( "Unable to replace attachment {$attachment_id} with file '{$file}'. Reason: File doesn't exist." );
833+
}
834+
$tempfile = $this->make_copy( $file );
835+
$name = Path::basename( $file );
836+
} else {
837+
$tempfile = download_url( $file );
838+
if ( is_wp_error( $tempfile ) ) {
839+
WP_CLI::error(
840+
sprintf(
841+
"Unable to replace attachment %d with file '%s'. Reason: %s",
842+
$attachment_id,
843+
$file,
844+
implode( ', ', $tempfile->get_error_messages() )
845+
)
846+
);
847+
}
848+
$name = (string) strtok( Path::basename( $file ), '?' );
849+
}
850+
851+
// Get old metadata before replacement for cleanup.
852+
$old_fullsizepath = $this->get_attached_file( $attachment_id );
853+
$old_metadata = wp_get_attachment_metadata( $attachment_id );
854+
855+
// Move the temp file into the uploads directory.
856+
$file_array = array(
857+
'name' => $name,
858+
'tmp_name' => $tempfile,
859+
);
860+
861+
$uploaded = wp_handle_sideload( $file_array, array( 'test_form' => false ) );
862+
863+
if ( isset( $uploaded['error'] ) ) {
864+
if ( isset( $tempfile ) && is_string( $tempfile ) && file_exists( $tempfile ) ) {
865+
unlink( $tempfile );
866+
}
867+
WP_CLI::error( "Failed to process file '{$orig_filename}': {$uploaded['error']}" );
868+
}
869+
870+
$new_file_path = $uploaded['file'];
871+
$new_mime_type = $uploaded['type'];
872+
873+
// Delete old thumbnail files unless asked to skip.
874+
if ( ! Utils\get_flag_value( $assoc_args, 'skip-delete' )
875+
&& false !== $old_fullsizepath
876+
&& is_array( $old_metadata )
877+
) {
878+
$this->remove_old_images( $old_metadata, $old_fullsizepath, array() );
879+
880+
// Also delete the previous full-size file itself to avoid leaving an orphan.
881+
if ( $old_fullsizepath !== $new_file_path && file_exists( $old_fullsizepath ) ) {
882+
@unlink( $old_fullsizepath );
883+
}
884+
885+
// For big-image scaling (WP 5.3+), delete the original image if present in metadata.
886+
$original_image = isset( $old_metadata['original_image'] ) ? (string) $old_metadata['original_image'] : '';
887+
if ( '' !== $original_image && ! empty( $old_metadata['file'] ) ) {
888+
$uploads = wp_get_upload_dir();
889+
if ( ! empty( $uploads['basedir'] ) ) {
890+
$dirname = dirname( $old_metadata['file'] );
891+
$original_image_rel = ( '.' === $dirname || '/' === $dirname ) ? $original_image : $dirname . '/' . $original_image;
892+
$original_image_abspath = $uploads['basedir'] . '/' . $original_image_rel;
893+
if ( $original_image_abspath !== $new_file_path && file_exists( $original_image_abspath ) ) {
894+
@unlink( $original_image_abspath );
895+
}
896+
}
897+
}
898+
}
899+
900+
// Update the attachment's MIME type.
901+
$updated = wp_update_post(
902+
array(
903+
'ID' => $attachment_id,
904+
'post_mime_type' => $new_mime_type,
905+
),
906+
true
907+
);
908+
if ( is_wp_error( $updated ) ) {
909+
WP_CLI::warning(
910+
sprintf( 'Failed to update MIME type for attachment %d: %s', $attachment_id, $updated->get_error_message() )
911+
);
912+
}
913+
914+
// Update the attached file path.
915+
update_attached_file( $attachment_id, $new_file_path );
916+
917+
// Generate and update new attachment metadata.
918+
$new_metadata = wp_generate_attachment_metadata( $attachment_id, $new_file_path );
919+
if ( is_array( $new_metadata ) && ! empty( $new_metadata ) ) {
920+
wp_update_attachment_metadata( $attachment_id, $new_metadata );
921+
} else {
922+
WP_CLI::warning(
923+
sprintf(
924+
'Failed to generate new attachment metadata for attachment ID %d. Existing metadata has been preserved.',
925+
$attachment_id
926+
)
927+
);
928+
}
929+
930+
if ( Utils\get_flag_value( $assoc_args, 'porcelain' ) ) {
931+
WP_CLI::line( (string) $attachment_id );
932+
} else {
933+
WP_CLI::log(
934+
sprintf( "Replaced file for attachment ID %d with '%s'.", $attachment_id, $orig_filename )
935+
);
936+
Utils\report_batch_operation_results( 'attachment', 'replace', 1, 1, 0 );
937+
}
938+
}
939+
778940
/**
779941
* Lists image sizes registered with WordPress.
780942
*

0 commit comments

Comments
 (0)