$this->slug(), 'risk' => 'medium', 'summary' => 'Finds image attachments whose registered thumbnail sizes are missing and ' . 'generates ONLY the missing files. Call after changing themes or image sizes, or when ' . 'the media library shows broken thumbnails. Dry-run lists affected attachments without ' . 'writing anything.', 'annotations' => array( 'destructive' => false, 'idempotent' => true, ), // Prefill for the admin Run panel (v1.4); agents may copy it too. 'example_input' => array( 'limit' => 5 ), 'input_schema' => array( 'type' => 'object', 'properties' => array( 'attachment_id' => array( 'type' => 'integer', 'minimum' => 1, 'description' => 'Check/regenerate a single attachment instead of scanning the library.', ), 'limit' => array( 'type' => 'integer', 'minimum' => 1, 'maximum' => 25, 'default' => 5, 'description' => 'Maximum number of attachments with missing sizes to process per call.', ), ), ), ); } public function dry_run( $atb_input ) { $atb_found = $this->find_missing( $atb_input ); if ( isset( $atb_found['refused'] ) ) { return $this->output( false, true, $atb_found ); } return $this->output( false, true, array( 'scanned' => $atb_found['scanned'], 'with_missing' => count( $atb_found['attachments'] ), 'attachments' => $atb_found['attachments'], 'scan_capped' => $atb_found['scan_capped'], ) ); } public function run( $atb_input ) { $atb_found = $this->find_missing( $atb_input ); if ( isset( $atb_found['refused'] ) ) { return $this->output( false, false, $atb_found ); } $this->load_image_functions(); $atb_started = microtime( true ); $atb_stopped_early = false; $atb_results = array(); $atb_regenerated = 0; $atb_failed = 0; $atb_warnings = array(); foreach ( $atb_found['attachments'] as $atb_item ) { if ( ( microtime( true ) - $atb_started ) > $this->time_budget() ) { $atb_stopped_early = true; break; } $atb_update = wp_update_image_subsizes( $atb_item['id'] ); if ( is_wp_error( $atb_update ) ) { $atb_failed++; $atb_item['failed'] = $atb_update->get_error_message(); $atb_warnings[] = 'attachment ' . $atb_item['id'] . ': ' . $atb_update->get_error_message(); } else { $atb_still_missing = array_keys( (array) wp_get_missing_image_subsizes( $atb_item['id'] ) ); $atb_item['regenerated'] = array_values( array_diff( $atb_item['missing'], $atb_still_missing ) ); $atb_regenerated += count( $atb_item['regenerated'] ); if ( ! empty( $atb_still_missing ) ) { $atb_item['still_missing'] = $atb_still_missing; } } $atb_results[] = $atb_item; } return $this->output( $atb_regenerated > 0, false, array( 'scanned' => $atb_found['scanned'], 'with_missing' => count( $atb_found['attachments'] ), 'attachments' => $atb_results, 'processed' => count( $atb_results ), 'regenerated_sizes' => $atb_regenerated, 'failed' => $atb_failed, 'stopped_early' => $atb_stopped_early, 'scan_capped' => $atb_found['scan_capped'], ), $atb_warnings ); } /** * Collects up to `limit` attachments that have missing subsizes. * * @param array $atb_input * @return array Either { refused: true, reason: string } or * { scanned: int, attachments: [{id, file, missing[]}...], scan_capped: bool }. */ protected function find_missing( $atb_input ) { $this->load_image_functions(); $atb_limit = isset( $atb_input['limit'] ) ? max( 1, min( 25, (int) $atb_input['limit'] ) ) : 5; if ( isset( $atb_input['attachment_id'] ) ) { $atb_id = (int) $atb_input['attachment_id']; $atb_post = get_post( $atb_id ); if ( ! $atb_post || 'attachment' !== $atb_post->post_type ) { return array( 'refused' => true, 'reason' => 'not_found' ); } if ( 0 !== strpos( (string) $atb_post->post_mime_type, 'image/' ) ) { return array( 'refused' => true, 'reason' => 'not_image' ); } $atb_ids = array( $atb_id ); $atb_capped = false; } else { $atb_ids = $this->image_attachment_ids(); $atb_capped = count( $atb_ids ) >= self::SCAN_CAP; } $atb_scanned = 0; $atb_attachments = array(); foreach ( $atb_ids as $atb_id ) { $atb_scanned++; $atb_missing = array_keys( (array) wp_get_missing_image_subsizes( (int) $atb_id ) ); if ( empty( $atb_missing ) ) { continue; } $atb_attachments[] = array( 'id' => (int) $atb_id, 'file' => (string) get_post_meta( (int) $atb_id, '_wp_attached_file', true ), 'missing' => $atb_missing, ); if ( count( $atb_attachments ) >= $atb_limit ) { break; } } return array( 'scanned' => $atb_scanned, 'attachments' => $atb_attachments, 'scan_capped' => $atb_capped, ); } /** * Newest-first image attachment IDs, capped at SCAN_CAP. * * @return int[] */ protected function image_attachment_ids() { global $wpdb; $atb_ids = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'attachment' AND post_mime_type LIKE %s ORDER BY ID DESC LIMIT %d", 'image/%', self::SCAN_CAP ) ); return array_map( 'intval', (array) $atb_ids ); } /** * Loads the wp-admin image functions outside admin context. No-op when * they already exist (real admin request, WP-CLI, or unit-test stubs). */ protected function load_image_functions() { if ( function_exists( 'wp_get_missing_image_subsizes' ) && function_exists( 'wp_update_image_subsizes' ) ) { return; } require_once ABSPATH . 'wp-admin/includes/image.php'; } /** * Wall-time budget in seconds; overridable in tests. * * @return int */ protected function time_budget() { return self::TIME_BUDGET; } } }