diff --git a/composer.json b/composer.json index 18dca436..c5a3779b 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,7 @@ } ], "require": { - "wp-cli/wp-cli": "^2.12" + "wp-cli/wp-cli": "^2.13" }, "require-dev": { "wp-cli/entity-command": "^1.3 || ^2", diff --git a/features/media-regenerate.feature b/features/media-regenerate.feature index 6b3412ce..2890d49c 100644 --- a/features/media-regenerate.feature +++ b/features/media-regenerate.feature @@ -984,6 +984,97 @@ Feature: Regenerate WordPress attachments """ And the return code should be 1 + Scenario: Provide error message when one of the multiple image sizes is non-existent + When I try `wp media regenerate --image_size=medium --image_size=test1` + Then STDERR should be: + """ + Error: Unknown image size "test1". + """ + And the return code should be 1 + + Scenario: Regenerate multiple specific image sizes + Given download: + | path | url | + | {CACHE_DIR}/canola.jpg | http://wp-cli.org/behat-data/canola.jpg | + And a wp-content/mu-plugins/media-settings.php file: + """ + ...] * : One or more IDs of the attachments to regenerate. * - * [--image_size=] - * : Name of the image size to regenerate. Only thumbnails of this image size will be regenerated, thumbnails of other image sizes will not. + * [--image_size=...] + * : Name of the image size to regenerate. Repeat the flag to specify multiple. Only thumbnails of specified image size(s) will be regenerated, thumbnails of other image sizes will not. * * [--skip-delete] * : Skip deletion of the original thumbnails. If your thumbnails are linked from sources outside your control, it's likely best to leave them around. Defaults to false. @@ -113,24 +113,49 @@ class Media_Command extends WP_CLI_Command { * 3/3 Regenerated "large" thumbnail for "Sunburst Over River" (ID 756). * Success: Regenerated 3 of 3 images. * + * # Re-generate only the thumbnails of "large" and "medium" image sizes for all images. + * $ wp media regenerate --image_size=large --image_size=medium + * Do you really want to regenerate the "large", "medium" image sizes for all images? [y/n] y + * Found 3 images to regenerate. + * 1/3 Regenerated "large", "medium" thumbnails for "Sydney Harbor Bridge" (ID 760). + * 2/3 No "large", "medium" thumbnail regeneration needed for "Boardwalk" (ID 757). + * 3/3 Regenerated "large", "medium" thumbnails for "Sunburst Over River" (ID 756). + * Success: Regenerated 3 of 3 images. + * * @param string[] $args Positional arguments. - * @param array{image_size?: string, 'skip-delete'?: bool, 'only-missing'?: bool, 'delete-unknown'?: bool, yes?: bool} $assoc_args Associative arguments. + * @param array{image_size?: string|string[], 'skip-delete'?: bool, 'only-missing'?: bool, 'delete-unknown'?: bool, yes?: bool} $assoc_args Associative arguments. * @return void */ public function regenerate( $args, $assoc_args = array() ) { - $assoc_args = wp_parse_args( - $assoc_args, - [ 'image_size' => '' ] - ); + // Extract image_size separately as it may be a string or an array of strings. + $image_size_raw = $assoc_args['image_size'] ?? null; + unset( $assoc_args['image_size'] ); + + // Normalize to an array: with WP-CLI 3.x and the ellipsis syntax, repeated flags yield an array. + // With earlier versions a single string is returned. + $image_sizes = array(); + if ( null !== $image_size_raw ) { + $image_sizes = is_array( $image_size_raw ) ? $image_size_raw : [ $image_size_raw ]; + } - $image_size = $assoc_args['image_size']; - if ( $image_size && ! in_array( $image_size, get_intermediate_image_sizes(), true ) ) { - WP_CLI::error( sprintf( 'Unknown image size "%s".', $image_size ) ); + if ( $image_sizes ) { + $registered_sizes = get_intermediate_image_sizes(); + foreach ( $image_sizes as $size ) { + if ( ! in_array( $size, $registered_sizes, true ) ) { + WP_CLI::error( sprintf( 'Unknown image size "%s".', $size ) ); + } + } } if ( empty( $args ) ) { - if ( $image_size ) { - WP_CLI::confirm( sprintf( 'Do you really want to regenerate the "%s" image size for all images?', $image_size ), $assoc_args ); + if ( $image_sizes ) { + WP_CLI::confirm( + sprintf( + 'Do you really want to regenerate the %s for all images?', + $this->get_image_sizes_description( $image_sizes, 'image size' ) + ), + $assoc_args + ); } else { WP_CLI::confirm( 'Do you really want to regenerate all images?', $assoc_args ); } @@ -169,8 +194,8 @@ public function regenerate( $args, $assoc_args = array() ) { ) ); - if ( $image_size ) { - $image_size_filters = $this->add_image_size_filters( $image_size ); + if ( $image_sizes ) { + $image_size_filters = $this->add_image_size_filters( $image_sizes ); } $number = 0; @@ -187,7 +212,7 @@ public function regenerate( $args, $assoc_args = array() ) { // @phpstan-ignore function.deprecated Utils\wp_clear_object_cache(); } - $this->process_regeneration( $post_id, $skip_delete, $only_missing, $delete_unknown, $image_size, $number . '/' . $count, $successes, $errors, $skips ); + $this->process_regeneration( $post_id, $skip_delete, $only_missing, $delete_unknown, $image_sizes, $number . '/' . $count, $successes, $errors, $skips ); } if ( isset( $image_size_filters ) ) { @@ -667,6 +692,21 @@ private function make_copy( $path ) { return $filename; } + /** + * Returns a human-readable description for one or more image size names. + * + * @param string[] $sizes The size names. + * @param string $noun Noun in singular form (e.g. 'thumbnail'); pluralized automatically. + * @param string $default_if_empty String to return when $sizes is empty. + * @return string + */ + private function get_image_sizes_description( array $sizes, $noun, $default_if_empty = '' ) { + if ( empty( $sizes ) ) { + return $default_if_empty; + } + return sprintf( '"%s" %s', implode( '", "', $sizes ), Utils\pluralize( $noun, count( $sizes ) ) ); + } + /** * Process media regeneration * @@ -674,7 +714,7 @@ private function make_copy( $path ) { * @param bool $skip_delete * @param bool $only_missing * @param bool $delete_unknown - * @param string $image_size + * @param string[] $image_sizes * @param string $progress * @param int $successes * @param int $errors @@ -684,7 +724,7 @@ private function make_copy( $path ) { * @param-out int $skips * @return void */ - private function process_regeneration( $id, $skip_delete, $only_missing, $delete_unknown, $image_size, $progress, &$successes, &$errors, &$skips ) { + private function process_regeneration( $id, $skip_delete, $only_missing, $delete_unknown, $image_sizes, $progress, &$successes, &$errors, &$skips ) { $title = get_the_title( $id ); if ( '' === $title ) { @@ -698,7 +738,7 @@ private function process_regeneration( $id, $skip_delete, $only_missing, $delete } else { $att_desc = sprintf( '"%1$s" (ID %2$d)', $title, $id ); } - $thumbnail_desc = $image_size ? sprintf( '"%s" thumbnail', $image_size ) : 'thumbnail'; + $thumbnail_desc = $this->get_image_sizes_description( $image_sizes, 'thumbnail', 'thumbnail' ); $fullsizepath = $this->get_attached_file( $id ); @@ -720,7 +760,7 @@ private function process_regeneration( $id, $skip_delete, $only_missing, $delete return; } - $needs_regeneration = $this->needs_regeneration( $id, $fullsizepath, $is_pdf, $image_size, $skip_delete, $skip_it ); + $needs_regeneration = $this->needs_regeneration( $id, $fullsizepath, $is_pdf, $image_sizes, $skip_delete, $skip_it ); if ( $skip_it ) { WP_CLI::log( "$progress Skipped $thumbnail_desc regeneration for $att_desc." ); @@ -735,12 +775,12 @@ private function process_regeneration( $id, $skip_delete, $only_missing, $delete } $site_icon_filter = $this->add_site_icon_filter( $id ); - // When regenerating a specific image size, use the file that WordPress normally + // When regenerating specific image size(s), use the file that WordPress normally // serves (the scaled version for big images), not the original pre-scaled file. // This prevents wp_generate_attachment_metadata() from re-creating the scaled // version or auto-rotating the original during specific-size regeneration. $generate_file = $fullsizepath; - if ( $image_size && ! $is_pdf ) { + if ( $image_sizes && ! $is_pdf ) { $wp_attached_file = \get_attached_file( $id ); if ( $wp_attached_file && file_exists( $wp_attached_file ) ) { $generate_file = $wp_attached_file; @@ -753,8 +793,8 @@ private function process_regeneration( $id, $skip_delete, $only_missing, $delete remove_filter( 'intermediate_image_sizes_advanced', $site_icon_filter ); } - // Note it's possible for no metadata to be generated for PDFs if restricted to a specific image size. - if ( empty( $metadata ) && ! ( $is_pdf && $image_size ) ) { + // Note it's possible for no metadata to be generated for PDFs if restricted to specific image size(s). + if ( empty( $metadata ) && ! ( $is_pdf && $image_sizes ) ) { WP_CLI::warning( sprintf( 'No metadata. (ID %d)', $id ) ); WP_CLI::log( "$progress Couldn't regenerate thumbnails for $att_desc." ); ++$errors; @@ -762,16 +802,17 @@ private function process_regeneration( $id, $skip_delete, $only_missing, $delete } // On read error, we might only get the filesize returned and nothing else. - if ( 1 === count( $metadata ) && array_key_exists( 'filesize', $metadata ) && ! ( $is_pdf && $image_size ) ) { + if ( 1 === count( $metadata ) && array_key_exists( 'filesize', $metadata ) && ! ( $is_pdf && $image_sizes ) ) { WP_CLI::warning( sprintf( 'Read error while retrieving metadata. (ID %d)', $id ) ); WP_CLI::log( "$progress Couldn't regenerate thumbnails for $att_desc." ); ++$errors; return; } - if ( $image_size ) { - if ( $this->update_attachment_metadata_for_image_size( $id, $metadata, $image_size, $original_meta ) ) { - WP_CLI::log( "$progress Regenerated $thumbnail_desc for $att_desc." ); + if ( $image_sizes ) { + $regenerated_sizes = $this->update_attachment_metadata_for_image_size( $id, $metadata, $image_sizes, $original_meta ); + if ( $regenerated_sizes ) { + WP_CLI::log( "$progress Regenerated {$this->get_image_sizes_description( $regenerated_sizes, 'thumbnail' )} for $att_desc." ); } else { WP_CLI::log( "$progress No $thumbnail_desc regeneration needed for $att_desc." ); } @@ -788,20 +829,20 @@ private function process_regeneration( $id, $skip_delete, $only_missing, $delete * * @param array $metadata * @param string $fullsizepath - * @param string $image_size + * @param string[] $image_sizes * @return void */ - private function remove_old_images( $metadata, $fullsizepath, $image_size ) { + private function remove_old_images( $metadata, $fullsizepath, $image_sizes ) { if ( empty( $metadata['sizes'] ) ) { return; } - if ( $image_size ) { - if ( empty( $metadata['sizes'][ $image_size ] ) ) { + if ( $image_sizes ) { + $metadata['sizes'] = array_intersect_key( $metadata['sizes'], array_flip( $image_sizes ) ); + if ( empty( $metadata['sizes'] ) ) { return; } - $metadata['sizes'] = array( $image_size => $metadata['sizes'][ $image_size ] ); } $dir_path = dirname( $fullsizepath ) . '/'; @@ -825,13 +866,13 @@ private function remove_old_images( $metadata, $fullsizepath, $image_size ) { * @param int $att_id * @param string $fullsizepath * @param bool $is_pdf - * @param string $image_size + * @param string[] $image_sizes * @param bool $skip_delete * @param bool $skip_it * @param-out bool $skip_it * @return bool */ - private function needs_regeneration( $att_id, $fullsizepath, $is_pdf, $image_size, $skip_delete, &$skip_it ) { + private function needs_regeneration( $att_id, $fullsizepath, $is_pdf, $image_sizes, $skip_delete, &$skip_it ) { // Assume not skipping. $skip_it = false; @@ -839,13 +880,13 @@ private function needs_regeneration( $att_id, $fullsizepath, $is_pdf, $image_siz // Note: zero-length string returned if no metadata, for instance if PDF or non-standard image (eg an SVG). $metadata = wp_get_attachment_metadata( $att_id ); - $image_sizes = $this->get_intermediate_image_sizes_for_attachment( $fullsizepath, $is_pdf, $metadata, $att_id ); + $attachment_sizes = $this->get_intermediate_image_sizes_for_attachment( $fullsizepath, $is_pdf, $metadata, $att_id ); // First check if no applicable editor currently available (non-destructive - ie old thumbnails not removed). - if ( is_wp_error( $image_sizes ) && 'image_no_editor' === $image_sizes->get_error_code() ) { + if ( is_wp_error( $attachment_sizes ) && 'image_no_editor' === $attachment_sizes->get_error_code() ) { // Warn unless PDF or non-standard image. if ( ! $is_pdf && is_array( $metadata ) && ! empty( $metadata['sizes'] ) ) { - WP_CLI::warning( sprintf( '%s (ID %d)', $image_sizes->get_error_message(), $att_id ) ); + WP_CLI::warning( sprintf( '%s (ID %d)', $attachment_sizes->get_error_message(), $att_id ) ); } $skip_it = true; return false; @@ -862,34 +903,43 @@ private function needs_regeneration( $att_id, $fullsizepath, $is_pdf, $image_siz // Remove any old thumbnails (so now destructive). if ( ! $skip_delete ) { - $this->remove_old_images( $metadata, $fullsizepath, $image_size ); + $this->remove_old_images( $metadata, $fullsizepath, $image_sizes ); } // Check for any other error (such as load error) apart from no editor available. - if ( is_wp_error( $image_sizes ) ) { + if ( is_wp_error( $attachment_sizes ) ) { // Warn but assume it may be possible to regenerate and allow processing to continue and possibly fail. - WP_CLI::warning( sprintf( '%s (ID %d)', $image_sizes->get_error_message(), $att_id ) ); + WP_CLI::warning( sprintf( '%s (ID %d)', $attachment_sizes->get_error_message(), $att_id ) ); return true; } // Have sizes - check whether they're new ones or they've changed. Note that an attachment can have no sizes if it's on or below the thumbnail threshold. - if ( $image_size ) { - if ( empty( $image_sizes[ $image_size ] ) ) { + if ( $image_sizes ) { + // Filter to only the requested sizes that are applicable to this attachment. + $filtered_sizes = array_intersect_key( $attachment_sizes, array_flip( $image_sizes ) ); + + if ( empty( $filtered_sizes ) ) { return false; } - if ( empty( $metadata['sizes'][ $image_size ] ) ) { - return true; + + // Check if any applicable requested size is missing from metadata. + foreach ( array_keys( $filtered_sizes ) as $size ) { + if ( empty( $metadata['sizes'][ $size ] ) ) { + return true; + } } /** * @var array{sizes: array>} $metadata */ - $metadata['sizes'] = array( $image_size => $metadata['sizes'][ $image_size ] ); + // Filter metadata and attachment_sizes to only the applicable requested sizes. + $metadata['sizes'] = array_intersect_key( $metadata['sizes'], $filtered_sizes ); + $attachment_sizes = $filtered_sizes; } - if ( $this->image_sizes_differ( $image_sizes, $metadata['sizes'] ) ) { + if ( $this->image_sizes_differ( $attachment_sizes, $metadata['sizes'] ) ) { return true; } @@ -1054,30 +1104,24 @@ private function get_intermediate_sizes( $is_pdf, $metadata, $att_id ) { } /** - * Add filters to only process a particular intermediate image size in wp_generate_attachment_metadata(). + * Add filters to only process particular intermediate image sizes in wp_generate_attachment_metadata(). * - * @param string $image_size + * @param string[] $image_sizes * @return array */ - private function add_image_size_filters( $image_size ) { + private function add_image_size_filters( $image_sizes ) { $image_size_filters = array(); // For images. - $image_size_filters['intermediate_image_sizes_advanced'] = function ( $sizes ) use ( $image_size ) { + $image_size_filters['intermediate_image_sizes_advanced'] = function ( $sizes ) use ( $image_sizes ) { // $sizes is associative array of name => size info entries. - if ( isset( $sizes[ $image_size ] ) ) { - return array( $image_size => $sizes[ $image_size ] ); - } - return array(); + return array_intersect_key( $sizes, array_flip( $image_sizes ) ); }; // For PDF previews. - $image_size_filters['fallback_intermediate_image_sizes'] = function ( $fallback_sizes ) use ( $image_size ) { + $image_size_filters['fallback_intermediate_image_sizes'] = function ( $fallback_sizes ) use ( $image_sizes ) { // $fallback_sizes is indexed array of size names. - if ( in_array( $image_size, $fallback_sizes, true ) ) { - return array( $image_size ); - } - return array(); + return array_values( array_intersect( $fallback_sizes, $image_sizes ) ); }; foreach ( $image_size_filters as $name => $filter ) { @@ -1158,34 +1202,41 @@ private function filter_upload_dir( $uploads ) { } /** - * Update attachment sizes metadata just for a particular intermediate image size. + * Update attachment sizes metadata just for particular intermediate image sizes. * * @param int $id * @param array $new_metadata - * @param string $image_size + * @param string[] $image_sizes * @param array{sizes: array}|false $metadata - * @return bool + * @return string[] The sizes that were actually regenerated. */ - private function update_attachment_metadata_for_image_size( $id, $new_metadata, $image_size, $metadata ) { + private function update_attachment_metadata_for_image_size( $id, $new_metadata, $image_sizes, $metadata ) { if ( ! is_array( $metadata ) ) { - return false; + return array(); } - // If have metadata for image_size. - if ( ! empty( $new_metadata['sizes'][ $image_size ] ) ) { - $metadata['sizes'][ $image_size ] = $new_metadata['sizes'][ $image_size ]; - wp_update_attachment_metadata( $id, $metadata ); - return true; + $regenerated_sizes = array(); + $changed = false; + + foreach ( $image_sizes as $image_size ) { + // If have metadata for image_size. + if ( ! empty( $new_metadata['sizes'][ $image_size ] ) ) { + $metadata['sizes'][ $image_size ] = $new_metadata['sizes'][ $image_size ]; + $regenerated_sizes[] = $image_size; + $changed = true; + } elseif ( ! empty( $metadata['sizes'][ $image_size ] ) ) { + // Else remove unused metadata if any. + unset( $metadata['sizes'][ $image_size ] ); + $changed = true; + // Treat removing unused metadata as no change (don't add to $regenerated_sizes). + } } - // Else remove unused metadata if any. - if ( ! empty( $metadata['sizes'][ $image_size ] ) ) { - unset( $metadata['sizes'][ $image_size ] ); + if ( $changed ) { wp_update_attachment_metadata( $id, $metadata ); - // Treat removing unused metadata as no change. } - return false; + return $regenerated_sizes; } /**