PluginProbe
Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization / trunk
Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization vtrunk
4.2.14 4.2.13 4.2.12 4.2.11 4.2.10 4.2.9 4.2.8 4.2.7 4.2.6 4.2.5 2.5.5 2.5.6 2.5.7 3.0.0 3.0.1 3.1.0 3.1.1 3.1.2 3.1.3 3.10.0 3.11.0 3.11.1 3.11.2 3.11.3 3.12.0 All 135 releases
optimole-wp / inc / media_rename / attachment_db_renamer.php

attachment_db_renamer.php in Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization trunk, at inc/media_rename/attachment_db_renamer.php

613 lines 16.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Attachment db renamer class.
4 */
5
6 /**
7 * URL Replacer for WordPress
8 *
9 * A standalone class to replace URLs in WordPress database,
10 * including handling image size variations and scaled images.
11 *
12 * @since 4.0.0
13 */
14 class Optml_Attachment_Db_Renamer {
15 /**
16 * Tables to skip during replacement
17 *
18 * @var array
19 */
20 private $skip_tables = [ 'users', 'terms', 'term_relationships', 'term_taxonomy' ];
21
22 /**
23 * Columns to skip during replacement
24 *
25 * @var array
26 */
27 private $skip_columns = [ 'user_pass' ];
28
29 /**
30 * Handle image size variations
31 *
32 * @var bool
33 */
34 private $handle_image_sizes = false;
35
36 /**
37 * Constructor
38 */
39 public function __construct( $skip_sizes = false ) {
40 $this->handle_image_sizes = ! $skip_sizes;
41 }
42
43 /**
44 * Replace URLs in the WordPress database
45 *
46 * @param string $old_url The base URL to search for (e.g., http://domain.com/wp-content/uploads/2025/03/image.jpg).
47 * @param string $new_url The base URL to replace with (e.g., http://domain.com/wp-content/uploads/2025/03/new-name.jpg).
48 *
49 * @return int Number of replacements made
50 */
51 public function replace( $old_url, $new_url ) {
52 if ( $old_url === $new_url ) {
53 return 0;
54 }
55
56 if ( empty( $old_url ) || empty( $new_url ) ) {
57 return 0;
58 }
59
60 if ( ! is_string( $old_url ) || ! is_string( $new_url ) ) {
61 return 0;
62 }
63
64 $tables = $this->get_tables();
65 $total_replacements = 0;
66
67 foreach ( $tables as $table ) {
68 if ( in_array( $table, $this->skip_tables, true ) ) {
69 continue;
70 }
71
72 list($primary_keys, $columns) = $this->get_columns( $table );
73
74 // Skip tables with no primary keys
75 if ( empty( $primary_keys ) ) {
76 continue;
77 }
78
79 foreach ( $columns as $column ) {
80 if ( in_array( $column, $this->skip_columns, true ) ) {
81 continue;
82 }
83
84 $replacements = $this->process_column( $table, $column, $primary_keys, $old_url, $new_url );
85 $total_replacements += $replacements;
86 }
87 }
88
89 return $total_replacements;
90 }
91
92 /**
93 * Get WordPress tables
94 *
95 * @return array Table names
96 */
97 private function get_tables() {
98 global $wpdb;
99
100 return array_values( $wpdb->tables() );
101 }
102
103 /**
104 * Get columns for a table
105 *
106 * @param string $table Table name.
107 *
108 * @return array Array containing primary keys and text columns
109 */
110 private function get_columns( $table ) {
111 global $wpdb;
112
113 $primary_keys = [];
114 $text_columns = [];
115
116 // Get table information
117 $results = $wpdb->get_results( $wpdb->prepare( 'DESCRIBE %i', $table ) );
118
119 if ( ! empty( $results ) ) {
120 // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
121 foreach ( $results as $col ) {
122 if ( 'PRI' === $col->Key ) {
123 $primary_keys[] = $col->Field;
124 }
125 if ( $this->is_text_col( $col->Type ) ) {
126 $text_columns[] = $col->Field;
127 }
128 }
129 // phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
130 }
131
132 return [ $primary_keys, $text_columns ];
133 }
134
135 /**
136 * Check if column is text type
137 *
138 * @param string $type Column type.
139 *
140 * @return bool True if text column
141 */
142 private function is_text_col( $type ) {
143 foreach ( [ 'text', 'varchar', 'longtext', 'mediumtext', 'char' ] as $token ) {
144 if ( false !== stripos( $type, $token ) ) {
145 return true;
146 }
147 }
148 return false;
149 }
150
151 /**
152 * Process a single column for replacements
153 *
154 * @param string $table Table name.
155 * @param string $column Column name.
156 * @param array $primary_keys Primary keys.
157 * @param string $old_url Old URL.
158 * @param string $new_url New URL.
159 *
160 * @return int Number of replacements
161 */
162 private function process_column( $table, $column, $primary_keys, $old_url, $new_url ) {
163 global $wpdb;
164
165 $count = 0;
166
167 // Check for serialized data
168 $has_serialized = $wpdb->get_var(
169 $wpdb->prepare(
170 "SELECT COUNT(%i) FROM %i WHERE %i REGEXP '^[aiO]:[1-9]' LIMIT 1",
171 $column,
172 $table,
173 $column
174 )
175 );
176
177 // Process with PHP if serialized data is found
178 if ( $has_serialized ) {
179 $count = $this->php_handle_column( $table, $column, $primary_keys, $old_url, $new_url );
180 } else {
181 // Use direct SQL replacement for non-serialized data
182 $count = $this->sql_handle_column( $table, $column, $old_url, $new_url );
183 }
184
185 return $count;
186 }
187
188 /**
189 * Handle column using SQL replacement
190 *
191 * @param string $table Table name.
192 * @param string $column Column name.
193 * @param string $old_url Old URL.
194 * @param string $new_url New URL.
195 *
196 * @return int Number of replacements
197 */
198 private function sql_handle_column( $table, $column, $old_url, $new_url ) {
199 global $wpdb;
200 $count = 0;
201
202 // Get the filename components
203 $old_path_parts = parse_url( $old_url );
204 if ( ! isset( $old_path_parts['path'] ) ) {
205 return 0;
206 }
207
208 $old_path = $old_path_parts['path'];
209 $old_file_info = pathinfo( $old_path );
210
211 $old_base = $old_file_info['filename'];
212 $old_dir = dirname( $old_path );
213 $old_domain = isset( $old_path_parts['host'] ) ? 'http' . ( isset( $old_path_parts['scheme'] ) && $old_path_parts['scheme'] === 'https' ? 's' : '' ) . '://' . $old_path_parts['host'] : '';
214
215 // Build pattern to match any URL containing the base filename
216 $base_url = $old_domain . $old_dir . '/' . $old_base;
217
218 // Get rows with regular URLs
219 $rows = $wpdb->get_results(
220 $wpdb->prepare(
221 'SELECT * FROM %i WHERE %i LIKE %s',
222 $table,
223 $column,
224 '%' . $wpdb->esc_like( $base_url ) . '%'
225 )
226 );
227
228 // Also create a pattern for JSON-escaped version
229 $json_base_url = str_replace( '/', '\/', $base_url );
230
231 // Get rows with JSON-escaped URLs
232 $json_rows = $wpdb->get_results(
233 $wpdb->prepare(
234 'SELECT * FROM %i WHERE %i LIKE %s',
235 $table,
236 $column,
237 '%' . $wpdb->esc_like( $json_base_url ) . '%'
238 )
239 );
240
241 // Merge results, avoiding duplicates
242 $processed_ids = [];
243 $all_rows = array_merge( $rows, $json_rows );
244
245 if ( empty( $all_rows ) ) {
246 return 0;
247 }
248
249 foreach ( $all_rows as $row ) {
250 $id_field = $row->ID ?? $row->id ?? null;
251 if ( ! $id_field ) {
252 foreach ( $row as $field => $value ) {
253 if ( stripos( $field, 'id' ) !== false ) {
254 $id_field = $value;
255 break;
256 }
257 }
258 }
259
260 if ( ! $id_field ) {
261 continue;
262 }
263
264 // Skip if we've already processed this row
265 if ( isset( $processed_ids[ $id_field ] ) ) {
266 continue;
267 }
268 $processed_ids[ $id_field ] = true;
269
270 $content = $row->$column;
271 $new_content = $this->replace_image_urls( $content, $old_url, $new_url );
272
273 if ( $content !== $new_content ) {
274 $wpdb->update(
275 $table,
276 [ $column => $new_content ],
277 [ 'ID' => $id_field ]
278 );
279 ++$count;
280 }
281 }
282
283 return $count;
284 }
285
286 /**
287 * Handle column using PHP for serialized data
288 *
289 * @param string $table Table name.
290 * @param string $column Column name.
291 * @param array $primary_keys Primary keys.
292 * @param string $old_url Old URL.
293 * @param string $new_url New URL.
294 *
295 * @return int Number of replacements
296 */
297 private function php_handle_column( $table, $column, $primary_keys, $old_url, $new_url ) {
298 global $wpdb;
299
300 $count = 0;
301 $json_old_url = str_replace( '/', '\/', $old_url );
302
303 // Build the query and allow processing with multiple primary keys.
304 $query = 'SELECT ';
305 foreach ( $primary_keys as $key => $value ) {
306 $query .= $wpdb->prepare( '%i, ', $value );
307 }
308 $query .= '%i FROM %i WHERE %s LIKE %s LIMIT 100';
309
310 // Get the rows that need updating - first for regular URLs
311 $rows = $wpdb->get_results(
312 $wpdb->prepare(
313 $query, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
314 $column,
315 $table,
316 $column,
317 '%' . $wpdb->esc_like( $old_url ) . '%'
318 )
319 );
320
321 // Also get rows with JSON-escaped URLs
322 $json_rows = $wpdb->get_results(
323 $wpdb->prepare(
324 $query, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
325 $column,
326 $table,
327 $column,
328 '%' . $wpdb->esc_like( $json_old_url ) . '%'
329 )
330 );
331
332 // Merge results, avoiding duplicates
333 $processed_ids = [];
334 $all_rows = array_merge( $rows, $json_rows );
335
336 foreach ( $all_rows as $row ) {
337 // Generate a unique identifier for this row based on primary keys
338 $row_id = '';
339 foreach ( $primary_keys as $key ) {
340 $row_id .= $row->$key . '|';
341 }
342
343 // Skip if we've already processed this row
344 if ( isset( $processed_ids[ $row_id ] ) ) {
345 continue;
346 }
347
348 $processed_ids[ $row_id ] = true;
349 $value = $row->$column;
350
351 // Skip empty values
352 if ( empty( $value ) ) {
353 continue;
354 }
355
356 // Replace URLs in the value (handling serialized data)
357 $new_value = $this->replace_urls_in_value( $value, $old_url, $new_url );
358
359 // Skip if no change
360 if ( $value === $new_value ) {
361 continue;
362 }
363
364 // Build WHERE clause for this row
365 $where_conditions = [];
366 foreach ( $primary_keys as $key ) {
367 $where_conditions[ $key ] = $row->$key;
368 }
369
370 // Update the row
371 $updated = $wpdb->update(
372 $table,
373 [ $column => $new_value ],
374 $where_conditions
375 );
376
377 if ( $updated ) {
378 ++$count;
379 }
380 }
381
382 return $count;
383 }
384
385 /**
386 * Replace URLs in a value, handling serialized data
387 *
388 * @param string $value The value to process.
389 * @param string $old_url Old URL.
390 * @param string $new_url New URL.
391 *
392 * @return string The processed value
393 */
394 private function replace_urls_in_value( $value, $old_url, $new_url ) {
395 // Check if the value is serialized
396 if ( $this->is_serialized( $value ) ) {
397 $unserialized = @unserialize( $value );
398
399 // If unserialize successful, process the data
400 if ( $unserialized !== false ) {
401 $replaced = $this->replace_in_data( $unserialized, $old_url, $new_url );
402 return serialize( $replaced );
403 }
404 }
405
406 // Handle image sizes for non-serialized content
407 if ( $this->handle_image_sizes ) {
408 return $this->replace_image_urls( $value, $old_url, $new_url );
409 }
410
411 // Simple string replacement for non-serialized data
412 return str_replace( $old_url, $new_url, $value );
413 }
414
415 /**
416 * Replace image URLs including various WordPress size variations and scaled images
417 *
418 * @param string $content The content to process.
419 * @param string $old_url Old URL pattern.
420 * @param string $new_url New URL pattern.
421 *
422 * @return string The processed content
423 */
424 private function replace_image_urls( $content, $old_url, $new_url ) {
425 // Get the filename components
426 $old_path_parts = parse_url( $old_url );
427 $new_path_parts = parse_url( $new_url );
428
429 if ( ! isset( $old_path_parts['path'] ) || ! isset( $new_path_parts['path'] ) ) {
430 // If we can't parse the URLs, fallback to direct replacement
431 return str_replace( $old_url, $new_url, $content );
432 }
433
434 // Extract file name info
435 $old_path = $old_path_parts['path'];
436 $new_path = $new_path_parts['path'];
437
438 $old_file_info = pathinfo( $old_path );
439 $new_file_info = pathinfo( $new_path );
440
441 $old_base = $old_file_info['filename'];
442 $new_base = $new_file_info['filename'];
443 $old_ext = isset( $old_file_info['extension'] ) ? $old_file_info['extension'] : '';
444 $new_ext = isset( $new_file_info['extension'] ) ? $new_file_info['extension'] : $old_ext;
445
446 // Define domain parts
447 $old_domain = isset( $old_path_parts['host'] ) ? 'http' . ( isset( $old_path_parts['scheme'] ) && $old_path_parts['scheme'] === 'https' ? 's' : '' ) . '://' . $old_path_parts['host'] : '';
448 $new_domain = isset( $new_path_parts['host'] ) ? 'http' . ( isset( $new_path_parts['scheme'] ) && $new_path_parts['scheme'] === 'https' ? 's' : '' ) . '://' . $new_path_parts['host'] : '';
449
450 // Replace original URLs
451 $content = str_replace( $old_url, $new_url, $content );
452
453 // Replace JSON-escaped URLs
454 $json_old_url = str_replace( '/', '\/', $old_url );
455 $json_new_url = str_replace( '/', '\/', $new_url );
456 $content = str_replace( $json_old_url, $json_new_url, $content );
457
458 // If we have a file with extension, handle variations
459 if ( ! empty( $old_ext ) && $this->handle_image_sizes ) {
460 $old_dir = dirname( $old_path );
461 $new_dir = dirname( $new_path );
462
463 // Replace WordPress image size variations (e.g., image-300x200.jpg)
464 $size_pattern = '/' . preg_quote( $old_domain . $old_dir . '/' . $old_base, '/' ) . '-\d+x\d+\.' . preg_quote( $old_ext, '/' ) . '/';
465
466 $content = preg_replace_callback(
467 $size_pattern,
468 function ( $matches ) use ( $old_base, $new_base, $old_domain, $new_domain, $old_dir, $new_dir, $old_ext, $new_ext ) {
469 // Extract the size part (e.g., -300x200)
470 $size_part = substr( $matches[0], strlen( $old_domain . $old_dir . '/' . $old_base ), -strlen( '.' . $old_ext ) );
471
472 // Build the new URL with the same size
473 return $new_domain . $new_dir . '/' . $new_base . $size_part . '.' . $new_ext;
474 },
475 $content
476 );
477
478 // Replace -scaled variations
479 $scaled_pattern = '/' . preg_quote( $old_domain . $old_dir . '/' . $old_base, '/' ) . '-scaled\.' . preg_quote( $old_ext, '/' ) . '/';
480
481 $content = preg_replace_callback(
482 $scaled_pattern,
483 function ( $matches ) use ( $new_base, $new_domain, $new_dir, $new_ext ) {
484 return $new_domain . $new_dir . '/' . $new_base . '-scaled.' . $new_ext;
485 },
486 $content
487 );
488
489 // Replace JSON-escaped variations
490 $json_size_pattern = '/' . preg_quote( str_replace( '/', '\/', $old_domain . $old_dir . '/' . $old_base ), '/' ) . '-\d+x\d+\.' . preg_quote( $old_ext, '/' ) . '/';
491
492 $content = preg_replace_callback(
493 $json_size_pattern,
494 function ( $matches ) use ( $old_base, $new_base, $old_domain, $new_domain, $old_dir, $new_dir, $old_ext, $new_ext ) {
495 // Extract the size part (e.g., -300x200)
496 $size_part = substr( $matches[0], strlen( str_replace( '/', '\/', $old_domain . $old_dir . '/' . $old_base ) ), -strlen( '.' . $old_ext ) );
497
498 // Build the new URL with the same size
499 return str_replace( '/', '\/', $new_domain . $new_dir . '/' . $new_base . $size_part . '.' . $new_ext );
500 },
501 $content
502 );
503
504 // Replace JSON-escaped scaled variations
505 $json_scaled_pattern = '/' . preg_quote( str_replace( '/', '\/', $old_domain . $old_dir . '/' . $old_base ), '/' ) . '-scaled\.' . preg_quote( $old_ext, '/' ) . '/';
506
507 $content = preg_replace_callback(
508 $json_scaled_pattern,
509 function ( $matches ) use ( $new_base, $new_domain, $new_dir, $new_ext ) {
510 return str_replace( '/', '\/', $new_domain . $new_dir . '/' . $new_base . '-scaled.' . $new_ext );
511 },
512 $content
513 );
514 }
515
516 return $content;
517 }
518
519 /**
520 * Recursively replace URLs in data structure
521 *
522 * @param mixed $data The data to process.
523 * @param string $old_url Old URL.
524 * @param string $new_url New URL.
525 *
526 * @return mixed The processed data
527 */
528 private function replace_in_data( $data, $old_url, $new_url ) {
529 if ( is_array( $data ) ) {
530 // Process arrays recursively
531 foreach ( $data as $key => $value ) {
532 $data[ $key ] = $this->replace_in_data( $value, $old_url, $new_url );
533 }
534 } elseif ( is_object( $data ) ) {
535 // Process objects recursively
536 foreach ( $data as $key => $value ) {
537 $data->$key = $this->replace_in_data( $value, $old_url, $new_url );
538 }
539 } elseif ( is_string( $data ) ) {
540 // Replace URLs in strings
541 if ( $this->handle_image_sizes ) {
542 $data = $this->replace_image_urls( $data, $old_url, $new_url );
543 } else {
544 $data = str_replace( $old_url, $new_url, $data );
545 }
546 }
547
548 return $data;
549 }
550
551 /**
552 * Check if a string is serialized
553 *
554 * @param string|mixed $data String to check.
555 *
556 * @return bool True if serialized
557 */
558 private function is_serialized( $data ) {
559 // If it isn't a string, it isn't serialized
560 if ( ! is_string( $data ) ) {
561 return false;
562 }
563
564 $data = trim( $data );
565 if ( 'N;' === $data ) {
566 return true;
567 }
568
569 if ( strlen( $data ) < 4 ) {
570 return false;
571 }
572
573 if ( ':' !== $data[1] ) {
574 return false;
575 }
576
577 $last_char = substr( $data, -1 );
578 if ( ';' !== $last_char && '}' !== $last_char ) {
579 return false;
580 }
581
582 $token = $data[0];
583 switch ( $token ) {
584 case 's':
585 if ( '"' !== substr( $data, -2, 1 ) ) {
586 return false;
587 }
588 // Fall through
589 case 'a':
590 case 'O':
591 case 'i':
592 case 'd':
593 return (bool) preg_match( "/^{$token}:[0-9]+:/", $data );
594 default:
595 return false;
596 }
597 }
598 }
599
600 /**
601 * Example usage:
602 *
603 * $replacer = new Optml_Attachment_Db_Renamer();
604 *
605 * // Replace all variations of the image
606 * $count = $replacer->replace(
607 * 'http://om-wp.test/wp-content/uploads/2025/03/image.jpg',
608 * 'http://om-wp.test/wp-content/uploads/2025/03/new-name.jpg'
609 * );
610 *
611 * echo "Replaced $count instances";
612 */
613