Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a CLI migration command to update attachment file names #63

Closed
wants to merge 9 commits into from
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
/vendor
composer.lock
110 changes: 110 additions & 0 deletions inc/class-tachyon-command.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php
/**
* CLI Commands for Tachyon.
*/

class Tachyon_Command extends WP_CLI_Command {

/**
* Update attachment file names to work with Tachyon.
*
* Certain file names that end in dimensions such as those produced by
* WordPress eg. example-150x150.jpg can cause problems when uploaded
* as an original image. This prevents Tachyon from accurately and
* performantly rewriting the post content.
*
* @subcommand migrate-files
* @synopsis [--network] [--sites-page=<int>] [--include-columns=<columns>]
*/
public function migrate_files( $args, $assoc_args ) {
global $wpdb;

$assoc_args = wp_parse_args( $assoc_args, [
'network' => false,
'sites-page' => 0,
'include-columns' => 'post_content,post_excerpt,meta_value'
] );

$sites = [ get_current_blog_id() ];
if ( $assoc_args['network'] ) {
$sites = get_sites( [
'fields' => 'ids',
'offset' => $assoc_args['sites-page'],
] );
}

// Get a reference to the search replace command class.
// The class uses the `__invoke()` magic method allowing it to be called like a function.
$search_replace = new Search_Replace_Command;

foreach ( $sites as $site_id ) {
switch_to_blog( $site_id );

if ( $assoc_args['network'] ) {
WP_CLI::log( "Processing site {$site_id}" );
}

$attachments = $wpdb->get_col( "SELECT post_id
FROM {$wpdb->postmeta}
WHERE meta_key = '_wp_attached_file'
AND meta_value REGEXP '-[[:digit:]]+x[[:digit:]]+\.(jpe?g|png|gif)$';" );

WP_CLI::log( sprintf( 'Renaming %d attachments', count( $attachments ) ) );

foreach ( $attachments as $attachment_id ) {
$result = Tachyon::_rename_file( $attachment_id );
if ( is_wp_error( $result ) ) {
WP_CLI::error( $result, false );
continue;
}

WP_CLI::success( sprintf( 'Renamed attachment %d successfully, performing search & replace.', $attachment_id ) );

// Add the full size to the array.
$result['old']['sizes']['full'] = [
'file' => $result['old']['file'],
];
$result['new']['sizes']['full'] = [
'file' => $result['new']['file'],
];

// Store all update queries into one transaction per image.
$wpdb->query( 'START TRANSACTION;' );

// Run search replace against each image size.
foreach ( $result['old']['sizes'] as $size => $size_data ) {
if ( ! isset( $result['new']['sizes'][ $size ] ) ) {
WP_CLI::error( sprintf( 'Size "%s" does not exist for updated attachment %d', $size, $attachment_id ), false );
continue;
}

WP_CLI::log( sprintf( 'Making replacements for size "%s" %s -> %s', $size, $size_data['file'], $result['new']['sizes'][ $size ]['file'] ) );

// Run search & replace.
$search_replace(
[
// Old.
$size_data['file'],
Copy link
Member

Choose a reason for hiding this comment

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

This is going to be reeeeaaalllyyy slow!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yep! I'm not sure it's really avoidable though. Would be great if search-replace supported arrays of things like str_replace()... Given it's a one time migration script it might not be so bad but this seems like the most robust way to do it.

Copy link
Member

Choose a reason for hiding this comment

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

Mmm ok, lets go with it for now then!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ryan had some better suggestions to make it faster using transactions so going to try that out today

// New.
$result['new']['sizes'][ $size ]['file'],
],
// Associative array args / command flags.
[
'include-columns' => $assoc_args['include-columns'],
'quiet' => true,
]
);
}

$wpdb->query( 'COMMIT;' );
}

restore_current_blog();
}

WP_CLI::log( 'Flushing cache...' );
wp_cache_flush();
WP_CLI::success( 'Done!' );
}

}
53 changes: 53 additions & 0 deletions inc/class-tachyon.php
Original file line number Diff line number Diff line change
Expand Up @@ -956,4 +956,57 @@ public function cleanup_rest_image_downsize( $response ) {
public function _override_image_downsize_in_rest_edit_context() {
return true;
}

/**
* Rename an existing attachment file to support Tachyon.
*
* Some legacy uploads may have image dimensions in the file name. Tachyon
* does not support this for performance reasons. This function renames
* attachments and returns an array containing the attachment ID, the old
* metadata and the new metadata.
*
* @param integer $attachment_id The attachment post ID.
* @return array|WP_Error
*/
public static function _rename_file( int $attachment_id ) {
if ( ! wp_attachment_is_image( $attachment_id ) ) {
return new WP_Error( 'tachyon_rename_file', sprintf( 'Attachment ID %d is not an image or does not exist', $attachment_id ) );
}

$file = get_attached_file( $attachment_id );
$metadata = wp_get_attachment_metadata( $attachment_id );

// Trim dimensions from file name and make sure it's actually different.
$new_file = preg_replace( '/-\d+x\d+\.(jpe?g|png|gif)$/', '.$1', $file );
if ( $file === $new_file ) {
return new WP_Error( 'tachyon_rename_file', "{$file} does not need to be renamed" );
}

// Make sure it's unique.
$new_file = wp_unique_filename( dirname( $file ), basename( $new_file ) );
$new_file = dirname( $file ) . DIRECTORY_SEPARATOR . $new_file;

// Copy old file to new name.
$copied = copy( $file, $new_file );
if ( ! $copied ) {
return new WP_Error( 'tachyon_rename_file', "{$file} could not be copied to {$new_file}" );
}

// Generate new metadata.
$new_metadata = wp_generate_attachment_metadata( $attachment_id, $new_file );
if ( empty( $new_metadata ) || ! is_array( $new_metadata ) ) {
return new WP_Error( 'tachyon_rename_file', "Could not generate new attachment metadata for attachment ID {$attachment_id}" );
}

// Update the attachment.
wp_update_attachment_metadata( $attachment_id, $new_metadata );
update_attached_file( $attachment_id, $new_file );
clean_attachment_cache( $attachment_id );

return [
'ID' => $attachment_id,
'old' => $metadata,
'new' => $new_metadata,
];
}
}
4 changes: 4 additions & 0 deletions tachyon.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
}

require_once( dirname( __FILE__ ) . '/inc/class-tachyon.php' );
if ( defined( 'WP_CLI' ) && WP_CLI ) {
require_once( dirname( __FILE__ ) . '/inc/class-tachyon-command.php' );
WP_CLI::add_command( 'tachyon', 'Tachyon_Command' );
}

Tachyon::instance();

Expand Down
2 changes: 1 addition & 1 deletion tests/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
// 2. Plugin installed inside of WordPress.org developer checkout
// 3. Tests checked out to /tmp
if ( false !== getenv( 'WP_DEVELOP_DIR' ) ) {
$test_root = getenv( 'WP_DEVELOP_DIR' ) . '/tests/phpunit';
$test_root = getenv( 'WP_DEVELOP_DIR' );
} elseif ( file_exists( '../../../../tests/phpunit/includes/bootstrap.php' ) ) {
$test_root = '../../../../tests/phpunit';
} elseif ( file_exists( '/tmp/wordpress-tests-lib/includes/bootstrap.php' ) ) {
Expand Down
Binary file added tests/data/tachyon-1280x719.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
117 changes: 117 additions & 0 deletions tests/tests/class-cli.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php
namespace HM\Tachyon\Tests;

use ReflectionClass;
use Tachyon;
use WP_UnitTestCase;

/**
* Ensure the tachyon plugin updates gallery and image links.
*
* @ticket 48
*/
class Tests_CLI extends WP_UnitTestCase {

/**
* @var int[] Attachment IDs
*/
static $attachment_ids;

/**
* @var array[] Original array of image sizes.
*/
static $wp_additional_image_sizes;

/**
* Set up attachments and posts require for testing.
*
* tachyon-1280x719.jpg: 1280x719
* Photo by Digital Buggu from Pexels
* @link https://www.pexels.com/photo/0-7-rpm-171195/
*/
static public function wpSetUpBeforeClass( $factory ) {
global $_wp_additional_image_sizes;
self::$wp_additional_image_sizes = $_wp_additional_image_sizes;

// Ensure pre WP 5.3.1 behaviour with image file having dimensions in file name.
add_filter( 'wp_unique_filename', __NAMESPACE__ . '\\Tests_CLI::unique_filename_override' );

self::$attachment_ids['tachyon-1280x719'] = $factory->attachment->create_upload_object(
realpath( __DIR__ . '/../data/tachyon-1280x719.jpg')
);

remove_filter( 'wp_unique_filename', __NAMESPACE__ . '\\Tests_CLI::unique_filename_override' );
}

/**
* Runs the routine after all tests have been run.
*
* This deletes the files from the uploads directory
* to account for the test suite returning the posts
* table to the original state.
*/
public static function wpTearDownAfterClass() {
global $_wp_additional_image_sizes;
$_wp_additional_image_sizes = self::$wp_additional_image_sizes;

$singleton = Tachyon::instance(); // Get Tachyon instance.
$reflection = new ReflectionClass( $singleton );
$instance = $reflection->getProperty( 'image_sizes' );
$instance->setAccessible( true ); // Allow modification of image sizes.
$instance->setValue( null, null ); // Reset image sizes for next tests.
$instance->setAccessible( false ); // clean up.

$uploads_dir = wp_upload_dir()['basedir'];

$files = glob( $uploads_dir . '/*' );
array_walk( $files, function ( $file ) {
if ( is_file( $file ) ) {
unlink($file);
}
} );
rmdir( $uploads_dir );
}

/**
* Prevents WP from fixing the file name during upload.
*
* This occurs if the file name contains dimensions as a suffix.
* This is to help test for backwards compat with WP 5.3.0 and earlier.
*
* @param string $filename
* @return string
*/
public static function unique_filename_override( $filename ) {
if ( strpos( $filename, 'tachyon-1280x719' ) === false ) {
return $filename;
}

return str_replace( '-1.jpg', '.jpg', $filename );
}

public function test_file_renaming() {
$attachment_id = self::$attachment_ids['tachyon-1280x719'];

// Get the file path.
$file = get_attached_file( $attachment_id );
$thumb_file = dirname( $file ) . '/' . basename( $file, '.jpg' ) . '-150x150.jpg';

// Confirm original file name.
$this->assertEquals( 'tachyon-1280x719.jpg', basename( $file ) );
// Confirm original exists.
$this->assertTrue( file_exists( $file ), "File $file exists" );
// Confirm a thumbnail exists.
$this->assertTrue( file_exists( $thumb_file ), "Thumbnail $thumb_file exists" );

// Rename the attachment.
$result = Tachyon::_rename_file( $attachment_id );
$this->assertTrue( is_array( $result ), 'Attachment renamed successfully' );

$new_file = get_attached_file( $attachment_id );

// Confirm new file name.
$this->assertEquals( 'tachyon-1.jpg', basename( $new_file ) );
// Confirm old original has been removed.
$this->assertTrue( file_exists( $new_file ), "File $new_file exists" );
}
}