cmbird-media-cleanup-cli.php
252 lines
| 1 | <?php |
| 2 | /** |
| 3 | * Plugin Name: Media Cleanup WP-CLI Command |
| 4 | */ |
| 5 | |
| 6 | if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) { |
| 7 | return; |
| 8 | } |
| 9 | |
| 10 | class CMBird_Media_Cleanup_Command { |
| 11 | |
| 12 | /** |
| 13 | * Cleanup WooCommerce product images. |
| 14 | * |
| 15 | * # # OPTIONS. |
| 16 | * |
| 17 | * [--dry-run] |
| 18 | * : Show what would be deleted without deleting anything. |
| 19 | * |
| 20 | * [--broken] |
| 21 | * : Remove attachments whose files no longer exist. |
| 22 | * |
| 23 | * [--duplicates] |
| 24 | * : Remove duplicate attachments based on filename suffix. |
| 25 | * |
| 26 | * [--products-only] |
| 27 | * : Limit cleanup to WooCommerce product & variation images. |
| 28 | * |
| 29 | * [--suffix-regex=<regex>] |
| 30 | * : Regex to detect duplicate suffix (no extension). |
| 31 | * --- |
| 32 | * default: -(\d{2})$ |
| 33 | * --- |
| 34 | * |
| 35 | * [--batch=<number>] |
| 36 | * : Number of attachments to process per batch. |
| 37 | * --- |
| 38 | * default: 300 |
| 39 | * --- |
| 40 | * |
| 41 | * # # EXAMPLES. |
| 42 | * |
| 43 | * wp media cleanup --dry-run --duplicates --products-only |
| 44 | * wp media cleanup --duplicates --suffix-regex='-(\d+)$' |
| 45 | * |
| 46 | * @when after_wp_load |
| 47 | */ |
| 48 | public function cleanup( $args, $assoc_args ) { |
| 49 | |
| 50 | if ( ! class_exists( 'WooCommerce' ) ) { |
| 51 | WP_CLI::error( 'WooCommerce is not active.' ); |
| 52 | } |
| 53 | |
| 54 | $dry_run = isset( $assoc_args['dry-run'] ); |
| 55 | $broken = isset( $assoc_args['broken'] ); |
| 56 | $duplicates = isset( $assoc_args['duplicates'] ); |
| 57 | $products_only = isset( $assoc_args['products-only'] ); |
| 58 | $batch = intval( $assoc_args['batch'] ?? 300 ); |
| 59 | $suffix_regex = $assoc_args['suffix-regex'] ?? '-(\d{2})$'; |
| 60 | |
| 61 | if ( ! $broken && ! $duplicates ) { |
| 62 | WP_CLI::error( 'You must specify --broken and/or --duplicates.' ); |
| 63 | } |
| 64 | |
| 65 | WP_CLI::log( 'Starting WooCommerce media cleanup...' ); |
| 66 | |
| 67 | $attachment_ids = $products_only |
| 68 | ? $this->get_product_image_ids() |
| 69 | : $this->get_all_image_ids(); |
| 70 | |
| 71 | $upload_dir = wp_get_upload_dir(); |
| 72 | $seen = array(); |
| 73 | $deleted = array( |
| 74 | 'broken' => 0, |
| 75 | 'duplicate' => 0, |
| 76 | ); |
| 77 | |
| 78 | foreach ( array_chunk( $attachment_ids, $batch ) as $chunk ) { |
| 79 | |
| 80 | $meta_map = $this->get_attachment_meta_map( $chunk ); |
| 81 | |
| 82 | foreach ( $chunk as $attachment_id ) { |
| 83 | |
| 84 | $meta = $meta_map[ $attachment_id ] ?? array(); |
| 85 | $file_meta = $meta['file'] ?? ''; |
| 86 | $relative = is_string( $file_meta ) ? $file_meta : ''; |
| 87 | $file = $relative ? trailingslashit( $upload_dir['basedir'] ) . $relative : ''; |
| 88 | $file_exists = $file ? file_exists( $file ) : false; |
| 89 | |
| 90 | /** |
| 91 | * Broken images |
| 92 | */ |
| 93 | if ( $broken && ( ! $file || ! $file_exists ) ) { |
| 94 | $this->delete_attachment( $attachment_id, 'BROKEN', $dry_run ); |
| 95 | $deleted['broken']++; |
| 96 | continue; |
| 97 | } |
| 98 | |
| 99 | /** |
| 100 | * Duplicate images (filename-based) |
| 101 | * Uses the stored file name or post slug instead of hitting the filesystem again. |
| 102 | */ |
| 103 | if ( $duplicates ) { |
| 104 | |
| 105 | $source_name = $relative ?: ( $meta['post_name'] ?? '' ); |
| 106 | $info = pathinfo( $source_name ); |
| 107 | $basename = strtolower( $info['filename'] ?? '' ); |
| 108 | $extension = strtolower( $info['extension'] ?? '' ); |
| 109 | |
| 110 | if ( $basename && preg_match( '/' . $suffix_regex . '/i', $basename ) ) { |
| 111 | |
| 112 | $base_key = preg_replace( '/' . $suffix_regex . '/i', '', $basename ); |
| 113 | $base_key = $extension ? $base_key . '.' . $extension : $base_key; |
| 114 | |
| 115 | if ( isset( $seen[ $base_key ] ) ) { |
| 116 | $this->delete_attachment( |
| 117 | $attachment_id, |
| 118 | 'DUPLICATE (suffix match)', |
| 119 | $dry_run |
| 120 | ); |
| 121 | $deleted['duplicate']++; |
| 122 | continue; |
| 123 | } |
| 124 | |
| 125 | $seen[ $base_key ] = $attachment_id; |
| 126 | } |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | WP_CLI::log( 'Processed batch of ' . count( $chunk ) ); |
| 131 | } |
| 132 | |
| 133 | WP_CLI::success( |
| 134 | sprintf( |
| 135 | 'Cleanup completed. Broken removed: %d, duplicates removed: %d.', |
| 136 | $deleted['broken'], |
| 137 | $deleted['duplicate'] |
| 138 | ) |
| 139 | ); |
| 140 | } |
| 141 | |
| 142 | /** |
| 143 | * Get all WooCommerce product-related image attachment IDs |
| 144 | */ |
| 145 | private function get_product_image_ids() { |
| 146 | |
| 147 | global $wpdb; |
| 148 | |
| 149 | $ids = array(); |
| 150 | |
| 151 | // Featured images. |
| 152 | $ids = array_merge( |
| 153 | $ids, |
| 154 | $wpdb->get_col( |
| 155 | " |
| 156 | SELECT meta_value FROM {$wpdb->postmeta} |
| 157 | WHERE meta_key = '_thumbnail_id' |
| 158 | " |
| 159 | ) |
| 160 | ); |
| 161 | |
| 162 | // Product galleries. |
| 163 | $gallery_ids = $wpdb->get_col( |
| 164 | " |
| 165 | SELECT meta_value FROM {$wpdb->postmeta} |
| 166 | WHERE meta_key = '_product_image_gallery' |
| 167 | " |
| 168 | ); |
| 169 | |
| 170 | foreach ( $gallery_ids as $gallery ) { |
| 171 | $ids = array_merge( $ids, array_filter( explode( ',', $gallery ) ) ); |
| 172 | } |
| 173 | |
| 174 | // Variation images. |
| 175 | $ids = array_merge( |
| 176 | $ids, |
| 177 | $wpdb->get_col( |
| 178 | " |
| 179 | SELECT meta_value FROM {$wpdb->postmeta} |
| 180 | WHERE meta_key = '_variation_thumbnail_id' |
| 181 | " |
| 182 | ) |
| 183 | ); |
| 184 | |
| 185 | return array_unique( array_map( 'intval', $ids ) ); |
| 186 | } |
| 187 | |
| 188 | /** |
| 189 | * Get all image attachment IDs (fallback) |
| 190 | */ |
| 191 | private function get_all_image_ids() { |
| 192 | |
| 193 | return get_posts( |
| 194 | array( |
| 195 | 'post_type' => 'attachment', |
| 196 | 'post_mime_type' => 'image', |
| 197 | 'posts_per_page' => -1, |
| 198 | 'fields' => 'ids', |
| 199 | ) |
| 200 | ); |
| 201 | } |
| 202 | |
| 203 | /** |
| 204 | * Prefetch attachment slug and file path to reduce per-ID queries. |
| 205 | */ |
| 206 | private function get_attachment_meta_map( $attachment_ids ) { |
| 207 | |
| 208 | if ( empty( $attachment_ids ) ) { |
| 209 | return array(); |
| 210 | } |
| 211 | |
| 212 | global $wpdb; |
| 213 | |
| 214 | $placeholders = implode( ',', array_fill( 0, count( $attachment_ids ), '%d' ) ); |
| 215 | |
| 216 | $rows = $wpdb->get_results( |
| 217 | $wpdb->prepare( |
| 218 | "SELECT p.ID, p.post_name, pm.meta_value AS file |
| 219 | FROM {$wpdb->posts} p |
| 220 | LEFT JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key = '_wp_attached_file' |
| 221 | WHERE p.ID IN ($placeholders)", |
| 222 | $attachment_ids |
| 223 | ), |
| 224 | ARRAY_A |
| 225 | ); |
| 226 | |
| 227 | $map = array(); |
| 228 | |
| 229 | foreach ( $rows as $row ) { |
| 230 | $map[ (int) $row['ID'] ] = array( |
| 231 | 'post_name' => $row['post_name'], |
| 232 | 'file' => $row['file'], |
| 233 | ); |
| 234 | } |
| 235 | |
| 236 | return $map; |
| 237 | } |
| 238 | |
| 239 | private function delete_attachment( $attachment_id, $reason, $dry_run ) { |
| 240 | |
| 241 | if ( $dry_run ) { |
| 242 | WP_CLI::log( "[DRY-RUN] {$reason} → Attachment ID {$attachment_id}" ); |
| 243 | return; |
| 244 | } |
| 245 | |
| 246 | wp_delete_attachment( $attachment_id, true ); |
| 247 | WP_CLI::log( "[DELETED] {$reason} → Attachment ID {$attachment_id}" ); |
| 248 | } |
| 249 | } |
| 250 | |
| 251 | WP_CLI::add_command( 'media cleanup', 'CMBird_Media_Cleanup_Command' ); |
| 252 |