PluginProbe ʕ •ᴥ•ʔ
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). / 2.8.4
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). v2.8.4
3.0.3 3.0.2 3.0.1 trunk 2.2.14 2.2.15 2.2.16 2.2.17 2.2.18 2.2.19 2.3.0 2.3.1 2.3.10 2.3.11 2.3.12 2.3.13 2.3.14 2.3.2 2.3.3 2.3.4 2.3.5 2.3.6 2.3.7 2.3.8 2.3.9 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 2.4.5 2.4.6 2.5.0 2.5.1 2.5.2 2.6.0 2.6.1 2.6.2 2.6.3 2.6.4 2.6.5 2.7.0 2.7.1 2.7.2 2.7.3 2.7.4 2.7.5 2.7.6 2.7.7 2.7.8 2.7.9 2.7.91 2.7.92 2.7.93 2.8.0 2.8.1 2.8.2 2.8.3 2.8.4 2.8.5 2.9.0 2.9.1 2.9.2 2.9.3 3.0.0
commercebird / mu-plugins / cmbird-media-cleanup-cli.php
commercebird / mu-plugins Last commit date
cmbird-media-cleanup-cli.php 5 months ago index.php 5 months ago
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