Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
bba8251
Plugin Directory: Extract asset image dimensions during plugin import.
dd32 May 11, 2026
6fdd329
Plugin Directory: Drop the dimensions_failed flag on asset records.
dd32 May 11, 2026
ed88db9
Plugin Directory: Use wp_remote_get limit_response_size instead of a …
dd32 May 11, 2026
7267d20
Plugin Directory: Use Template::get_asset_url() to build the asset URL.
dd32 May 11, 2026
1165377
Plugin Directory: Slim down the enrich_asset_dimensions() docblock.
dd32 May 11, 2026
0b039e6
Plugin Directory: Fix phpcs violations.
dd32 May 11, 2026
06399c0
Plugin Directory: Send a real Range header on the first asset fetch.
dd32 May 11, 2026
2ae4dc7
Plugin Directory: Bump HTTP timeout to 30s for the backfill bin script.
dd32 May 11, 2026
87d08da
Plugin Directory: Trim the asset-fetch inline comments.
dd32 May 11, 2026
9e73d98
Plugin Directory: Restore the /* no CDN */ inline comment on the URL …
dd32 May 11, 2026
e9adac4
Plugin Directory: Drop null from the enrich_asset_dimensions() $post …
dd32 May 12, 2026
484eb6c
Merge branch 'trunk' into add/claude/extract-asset-resolutions-on-import
dd32 May 12, 2026
2f9883f
Plugin Directory: Use KB_IN_BYTES for the asset-fetch range size.
dd32 May 12, 2026
a269d04
Plugin Directory: Narrow enrich_asset_dimensions() $post docblock to …
dd32 May 12, 2026
bf77b4d
Plugin Directory: Inline get_post_meta() into the $prior_assets literal.
dd32 May 12, 2026
4bf9a74
Plugin Directory: Stop silencing getimagesize() warnings with @.
dd32 May 12, 2026
c611315
Plugin Directory: Document why the asset-fetch loop breaks on transpo…
dd32 May 12, 2026
7a4ad26
Plugin Directory: Switch to wp_getimagesize() and streamed downloads.
dd32 May 12, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
<?php
// phpcs:ignoreFile — CLI script: output not escaped, loop vars shadow WP globals.
namespace WordPressdotorg\Plugin_Directory;

use WordPressdotorg\Plugin_Directory\CLI\Import;

// This script should only be called in a CLI environment.
if ( 'cli' != php_sapi_name() ) {
die();
}

$opts = getopt( '', array( 'url:', 'abspath:', 'plugin:', 'limit:', 'dry-run' ) );

if ( empty( $opts['url'] ) ) {
$opts['url'] = 'https://wordpress.org/plugins/';
}
if ( empty( $opts['abspath'] ) && false !== strpos( __DIR__, 'wp-content' ) ) {
$opts['abspath'] = substr( __DIR__, 0, strpos( __DIR__, 'wp-content' ) );
}

$opts['dry-run'] = isset( $opts['dry-run'] );
$opts['limit'] = isset( $opts['limit'] ) ? (int) $opts['limit'] : 0;

// Bootstrap WordPress.
$_SERVER['HTTP_HOST'] = parse_url( $opts['url'], PHP_URL_HOST );
$_SERVER['REQUEST_URI'] = parse_url( $opts['url'], PHP_URL_PATH );

require rtrim( $opts['abspath'], '/' ) . '/wp-load.php';

if ( ! class_exists( '\WordPressdotorg\Plugin_Directory\Plugin_Directory' ) ) {
fwrite( STDERR, "Error! This site doesn't have the Plugin Directory plugin enabled.\n" );
if ( defined( 'WPORG_PLUGIN_DIRECTORY_BLOGID' ) ) {
fwrite( STDERR, "Run the following command instead:\n" );
fwrite( STDERR, "\tphp " . implode( ' ', $argv ) . ' --url ' . get_site_url( WPORG_PLUGIN_DIRECTORY_BLOGID, '/' ) . "\n" );
}
die();
}

global $wpdb;

// Be more patient with slow SVN responses during a bulk run than the
// per-request import path is. Uses http_request_args (not the timeout
// filter) so the helper's explicit `timeout => 15` is overridden.
add_filter(
'http_request_args',
function ( $args ) {
$args['timeout'] = 30;
return $args;
}
);

$meta_keys = array(
'screenshot' => 'assets_screenshots',
'banner' => 'assets_banners',
'icon' => 'assets_icons',
);

if ( ! empty( $opts['plugin'] ) ) {
$plugin = Plugin_Directory::get_plugin_post( $opts['plugin'] );
if ( ! $plugin ) {
fwrite( STDERR, "Plugin '{$opts['plugin']}' not found.\n" );
exit( 1 );
}
$post_ids = array( $plugin->ID );
} else {
$post_ids = $wpdb->get_col( $wpdb->prepare(
"SELECT DISTINCT post_id
FROM {$wpdb->postmeta}
WHERE meta_key IN ( %s, %s, %s )
AND meta_value != ''
AND meta_value != 'a:0:{}'
ORDER BY post_id ASC",
$meta_keys['screenshot'],
$meta_keys['banner'],
$meta_keys['icon']
) );

if ( $opts['limit'] > 0 ) {
$post_ids = array_slice( $post_ids, 0, $opts['limit'] );
}
}

$total_plugins = count( $post_ids );
$plugins_updated = 0;
$counts = array(
'reused' => 0,
'extracted' => 0,
'failed' => 0,
);
$failed_files = array();

echo "Scanning {$total_plugins} plugins" . ( $opts['dry-run'] ? ' (dry-run)' : '' ) . "...\n";

foreach ( $post_ids as $i => $post_id ) {
$plugin = get_post( $post_id );
if ( ! $plugin || 'plugin' !== $plugin->post_type ) {
continue;
}

$slug = $plugin->post_name; // Only used for the failure list.
$plugin_dirty = false;

foreach ( $meta_keys as $type => $meta_key ) {
$records = get_post_meta( $post_id, $meta_key, true );
if ( ! is_array( $records ) || ! $records ) {
continue;
}

$meta_dirty = false;

foreach ( $records as $filename => $record ) {
if ( ! is_array( $record ) || empty( $record['filename'] ) || ! isset( $record['revision'] ) ) {
continue;
}

if ( ! empty( $record['width'] ) && ! empty( $record['height'] ) ) {
++$counts['reused'];
continue;
}

// Pass the existing record as the prior so the helper short-circuits
// for cases it already knows the answer for.
$updated = Import::enrich_asset_dimensions( $record, $record, $plugin );

if ( ! empty( $updated['width'] ) && ! empty( $updated['height'] ) ) {
++$counts['extracted'];
} else {
++$counts['failed'];
$failed_files[] = "{$slug} {$type} {$record['filename']}";
}

if ( $updated !== $record ) {
$records[ $filename ] = $updated;
$meta_dirty = true;
}
}

if ( $meta_dirty ) {
$plugin_dirty = true;
if ( ! $opts['dry-run'] ) {
update_post_meta( $post_id, $meta_key, wp_slash( $records ) );
}
}
}

if ( $plugin_dirty ) {
++$plugins_updated;
}

if ( ( $i + 1 ) % 100 === 0 ) {
echo sprintf(
" [%d/%d] reused=%d extracted=%d failed=%d\n",
$i + 1,
$total_plugins,
$counts['reused'],
$counts['extracted'],
$counts['failed']
);
}
}

echo "\nDone.\n";
echo " Plugins scanned: {$total_plugins}\n";
echo " Plugins updated: {$plugins_updated}" . ( $opts['dry-run'] ? ' (would update)' : '' ) . "\n";
echo " Assets with cached dimensions reused: {$counts['reused']}\n";
echo " Assets newly extracted: {$counts['extracted']}\n";
echo " Assets failed extraction: {$counts['failed']}\n";

if ( $failed_files ) {
echo "\nFailed assets:\n " . implode( "\n ", $failed_files ) . "\n";
}
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,14 @@ protected function export_and_parse_plugin( $plugin_slug ) {
'blueprint' => 100 * KB_IN_BYTES,
);

// Previously-imported asset metadata, used to skip re-reading any file whose
// SVN revision hasn't changed.
$prior_assets = array(
'screenshot' => get_post_meta( $this->plugin->ID, 'assets_screenshots', true ) ?: array(),
'banner' => get_post_meta( $this->plugin->ID, 'assets_banners', true ) ?: array(),
'icon' => get_post_meta( $this->plugin->ID, 'assets_icons', true ) ?: array(),
);

$svn_blueprints_folder = null;
$svn_assets_folder = SVN::ls( self::PLUGIN_SVN_BASE . "/{$plugin_slug}/assets/", true /* verbose */ );
if ( $svn_assets_folder ) { // /assets/ may not exist.
Expand Down Expand Up @@ -862,7 +870,15 @@ protected function export_and_parse_plugin( $plugin_slug ) {
$resolution = preg_replace( '/[^0-9]/u', 'x', $resolution );
}

$assets[ $type ][ $asset['filename'] ] = compact( 'filename', 'revision', 'resolution', 'location', 'locale' );
$record = compact( 'filename', 'revision', 'resolution', 'location', 'locale' );

$record = self::enrich_asset_dimensions(
$record,
$prior_assets[ $type ][ $filename ] ?? null,
$this->plugin
);

$assets[ $type ][ $asset['filename'] ] = $record;
}
}

Expand Down Expand Up @@ -921,12 +937,21 @@ protected function export_and_parse_plugin( $plugin_slug ) {
continue;
}

$assets['screenshot'][ $filename ] = array(
$record = array(
'filename' => $filename,
'revision' => $svn_export['revision'],
'resolution' => $screenshot_id,
'location' => 'plugin',
);

$record = self::enrich_asset_dimensions(
$record,
$prior_assets['screenshot'][ $filename ] ?? null,
$this->plugin,
$plugin_screenshot
);

$assets['screenshot'][ $filename ] = $record;
}

if ( 'trunk' === $stable_tag ) {
Expand Down Expand Up @@ -1068,6 +1093,84 @@ function( $block ) {
);
}

/**
* Populate `width` and `height` on an asset record, reusing the prior
* import's values when the SVN revision hasn't changed.
*
* @param array $record The asset record.
* @param array|null $prior Matching record from the prior import.
* @param \WP_Post $post The plugin post.
* @param string|null $local Optional local path to read instead of fetching from SVN.
* @return array
*/
public static function enrich_asset_dimensions( $record, $prior, $post, $local = null ) {
if (
is_array( $prior ) &&
isset( $prior['revision'], $prior['width'], $prior['height'] ) &&
(string) $prior['revision'] === (string) $record['revision'] &&
$prior['width'] > 0 && $prior['height'] > 0
) {
$record['width'] = (int) $prior['width'];
$record['height'] = (int) $prior['height'];

return $record;
}

$size = false;

if ( $local && file_exists( $local ) ) {
$size = wp_getimagesize( $local );
}

if ( ! $size ) {
$url = Template::get_asset_url( $post, $record, false /* no CDN */ );
$temp_file = wp_tempnam( $record['filename'] );

// Range the first read to 128 KB — enough for the headers of
// most images. Fall back to a full read only when the prefix
// isn't enough to decode the header — the falsy `$size` at
// the bottom of the loop is the implicit retry. Transport
// errors / non-2xx intentionally bail out via `break`: those
// failure modes won't be helped by re-requesting the same
// URL without Range.
foreach ( array( 128 * KB_IN_BYTES, 0 ) as $limit ) {
$args = array(
'timeout' => 15,
'stream' => true,
'filename' => $temp_file,
);
if ( $limit > 0 ) {
$args['headers'] = array( 'Range' => 'bytes=0-' . ( $limit - 1 ) );
$args['limit_response_size'] = $limit;
}

$response = wp_safe_remote_get( $url, $args );
$code = wp_remote_retrieve_response_code( $response );
if ( is_wp_error( $response ) || ( 200 !== $code && 206 !== $code ) ) {
break;
}

if ( ! file_exists( $temp_file ) || 0 === filesize( $temp_file ) ) {
break;
}

$size = wp_getimagesize( $temp_file );
if ( $size ) {
Comment on lines +1155 to +1158
break;
Comment on lines +1121 to +1159
}
}

unlink( $temp_file );
}

if ( $size && ! empty( $size[0] ) && ! empty( $size[1] ) ) {
$record['width'] = (int) $size[0];
$record['height'] = (int) $size[1];
}

return $record;
}

/**
* Find the plugin readme file.
*
Expand Down
Loading