PluginProbe
Search & Replace Everything by WPCode – Find and Replace Media, Text, Links, and More / 1.0.8
Search & Replace Everything by WPCode – Find and Replace Media, Text, Links, and More v1.0.8
trunk 1.0.0 1.0.1 1.0.10 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9
search-replace-wpcode / includes / class-wsrw-search-replace.php

class-wsrw-search-replace.php in Search & Replace Everything by WPCode – Find and Replace Media, Text, Links, and More 1.0.8, at includes/class-wsrw-search-replace.php

653 lines 19.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Where we do the searching and replacing.
4 *
5 * @package Search_Replace_WPCode
6 */
7
8 if ( ! defined( 'ABSPATH' ) ) {
9 exit;
10 }
11
12 /**
13 * Class WSRW_Search_Replace
14 */
15 class WSRW_Search_Replace {
16
17 /**
18 * The number of rows to process at a time.
19 *
20 * @var int
21 */
22 public $page_size = 1000;
23
24 /**
25 * The process data.
26 *
27 * @var array
28 */
29 public $process;
30
31 /**
32 * WSRW_Search_Replace constructor.
33 */
34 public function __construct() {
35 $this->ajax_hooks();
36 }
37
38 /**
39 * Add the ajax hooks.
40 *
41 * @return void
42 */
43 public function ajax_hooks() {
44 add_action( 'wp_ajax_wsrw_start_search_replace', array( $this, 'ajax_prepare_search_replace' ) );
45 add_action( 'wp_ajax_wsrw_do_search_replace', array( $this, 'ajax_do_search_replace' ) );
46 }
47
48 /**
49 * The callback for the ajax endpoint to start the search & replace process.
50 *
51 * @return void
52 */
53 public function ajax_prepare_search_replace() {
54 check_admin_referer( 'wsrw_admin', 'nonce' );
55
56 if ( ! current_user_can( 'manage_options' ) ) {
57 wp_send_json_error( __( 'You do not have permission to do this.', 'search-replace-wpcode' ) );
58 }
59
60 $search = isset( $_POST['search'] ) ? sanitize_text_field( wp_unslash( $_POST['search'] ) ) : '';
61 $replace = isset( $_POST['replace'] ) ? sanitize_text_field( wp_unslash( $_POST['replace'] ) ) : '';
62 $dry_run = ! isset( $_POST['dry_run'] ) || boolval( $_POST['dry_run'] );
63 $case_insensitive = isset( $_POST['case_insensitive'] );
64
65 if ( empty( $search ) ) {
66 wp_send_json_error( __( 'Please enter a search term.', 'search-replace-wpcode' ) );
67 }
68
69 $tables = isset( $_POST['tables'] ) ? array_map( 'sanitize_text_field', wp_unslash( $_POST['tables'] ) ) : array();
70
71 $tables = $this->validate_tables( $tables );
72
73 global $wpdb;
74
75 if ( in_array( $wpdb->options, $tables ) ) {
76 $tables = array_diff( $tables, array( $wpdb->options ) );
77 $tables = array_values( $tables );
78 $tables[] = $wpdb->options;
79 }
80
81 $response = array(
82 'search' => $search,
83 'replace' => $replace,
84 'pages' => $this->get_all_pages( $tables ),
85 'tables' => $tables,
86 'page' => 0,
87 'table' => 0,
88 'table_page' => 0,
89 'dry_run' => $dry_run,
90 'case_insensitive' => $case_insensitive,
91 );
92
93 if ( isset( $_POST['checked_items'] ) ) {
94 $response['checked_items'] = json_decode( sanitize_text_field( wp_unslash( $_POST['checked_items'] ) ), true );
95 }
96
97 update_option( 'wsrw_process', $response, false );
98
99 do_action( 'wsrw_start_search_replace', $response );
100
101 wp_send_json_success( $response );
102 }
103
104 /**
105 * Get the process data.
106 *
107 * @return array
108 */
109 public function get_process() {
110 if ( ! isset( $this->process ) ) {
111 $this->process = get_option( 'wsrw_process', array() );
112 }
113
114 return $this->process;
115 }
116
117 /**
118 * Highlight the search results.
119 *
120 * @param string $needle The search term.
121 * @param string $haystack The content to search in.
122 * @param string $color The color of the highlight.
123 * @param array $positions The positions of the search term.
124 * @param string $replaced_string The string that was replaced.
125 * @param bool $case_insensitive Whether the search is case insensitive.
126 *
127 * @return array
128 */
129 public function highlight_results( $needle, $haystack, $color = 'yellow', $positions = array(), $replaced_string = '', $case_insensitive = false ) {
130
131 $offset = 0;
132 $string_offset = 0;
133 $search = $case_insensitive ? 'stripos' : 'strpos';
134 if ( empty( $positions ) ) {
135 $positions = array();
136
137 $pos = $search( $haystack, $needle, $offset );
138 while ( false !== $pos ) {
139 // Let's make sure we get the correct position here.
140 $positions[] = $pos;
141 $offset = $pos + 1;
142 $pos = $search( $haystack, $needle, $offset );
143 }
144 } else {
145 $string_offset = strlen( $replaced_string ) - strlen( $needle );
146 }
147
148 $trimmed_contents = array();
149 foreach ( $positions as $i => $pos ) {
150 $pos = $pos - $string_offset * ( $i );
151 $start = max( 0, $pos - 50 ); // 50 characters before
152 $length = strlen( $needle ) + 100; // The replace string and 50 characters after.
153 $trimmed = substr( $haystack, $start, $length );
154 $actual_length = $pos - $start;
155 $actual_end = $length - $actual_length - strlen( $needle );
156 // Let's add the highlight span.
157 $trimmed = substr_replace( $trimmed, '<span class="wsrw-highlight wsrw-highlight-' . $color . '">', $actual_length, 0 );
158 $trimmed = substr_replace( $trimmed, '</span>', - $actual_end, 0 );
159 $trimmed_contents[] = $trimmed;
160 }
161
162 $haystack = implode( '... ...', $trimmed_contents );
163
164 return array(
165 'positions' => $positions,
166 'highlighted' => $haystack,
167 );
168 }
169
170
171 /**
172 * The callback for the ajax endpoint to do the search & replace process.
173 *
174 * @return void
175 */
176 public function ajax_do_search_replace() {
177 check_admin_referer( 'wsrw_admin', 'nonce' );
178
179 if ( ! current_user_can( 'manage_options' ) ) {
180 wp_send_json_error( __( 'You do not have permission to do this.', 'search-replace-wpcode' ) );
181 }
182
183 // Let's see if we have the process data saved.
184 $process = $this->get_process();
185
186 // If we don't have any process data, we can't do anything.
187 if ( empty( $process ) ) {
188 wp_send_json_error( __( 'No process data found.', 'search-replace-wpcode' ) );
189 }
190
191 $table = $process['table'];
192 $table_name = $process['tables'][ $table ];
193 // Escape the table name since we support WordPress versions that don't have the %i placeholder added in WP 6.2.
194 $table_name = esc_sql( $table_name );
195
196 // Let's get the page we are currently on.
197 $table_page = $process['table_page'];
198
199 global $wpdb;
200
201 $page_size = $this->get_page_size();
202 $offset = $page_size * $table_page;
203
204 // Let's get all the rows in the table with a limit from $this->get_page_size() and the offset from the current page.
205 $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM $table_name LIMIT %d, %d", $offset, $page_size ) ); // phpcs:ignore
206
207 $columns_data = self::get_table_columns( $table_name );
208 $columns = $columns_data['columns'];
209 $primary_key = $columns_data['primary_key'];
210
211 $updated_data = array();
212
213 foreach ( $rows as $row ) {
214 $where_clause = array();
215 $update_clause = array();
216
217 if ( isset( $row->option_name ) && 'siteurl' === $row->option_name ) {
218 continue;
219 }
220
221 foreach ( $columns as $column ) {
222 $content = $row->$column;
223
224 if ( $table_name === $wpdb->options ) {
225 if ( isset( $skip ) && true === $skip ) {
226 $skip = false;
227 continue;
228 }
229
230 if ( 'wsrw_process' === $content ) {
231 $skip = true;
232 continue;
233 }
234 }
235
236 if ( $primary_key === $column ) {
237 $where_clause[] = $column . '= "' . $this->mysql_real_escape_string( $content ) . '"';
238 continue;
239 }
240
241 if ( apply_filters( 'wsrw_skip_guids', 'guid' === $column ) ) {
242 continue;
243 }
244
245 $case_insensitive = boolval( $process['case_insensitive'] );
246 $replaced_content = $this->run_replace( $process['search'], $process['replace'], $content, $case_insensitive );
247
248 if ( $content !== $replaced_content ) {
249 $update_clause[] = $column . ' = "' . $this->mysql_real_escape_string( $replaced_content ) . '"';
250
251 $highlighted_results = $this->highlight_replacements( $process['search'], $process['replace'], $content, $replaced_content, $case_insensitive );
252
253 $operation_data = array(
254 'table' => $table_name,
255 'column' => $column,
256 'row' => $row->$primary_key,
257 'old' => $highlighted_results['old'],
258 'new' => $highlighted_results['new'],
259 );
260
261 $updated_data[] = $operation_data;
262
263 do_action( 'wsrw_performed_search_replace', $process, $content, $operation_data );
264
265 }
266 }
267
268 if ( ! $process['dry_run'] && ! empty( $update_clause ) ) {
269 // Let's update the row.
270 // If we do a prepared query here or attempt to use $wpdb->update, we will have issues with serialized data or in general break the values of many types of content, so instead we escape the values when the arrays are built.
271 $update_sql = "UPDATE $table_name SET " . implode( ', ', $update_clause ) . " WHERE " . implode( ' AND ', $where_clause ); // phpcs:ignore
272 $wpdb->query( $update_sql ); // phpcs:ignore
273 }
274 }
275
276 // Let's update the process data.
277 $process['table_page'] = $table_page + 1;
278 $process['page'] = $process['page'] + 1;
279
280 if ( count( $rows ) < $this->get_page_size() ) {
281 $process['table'] = $process['table'] + 1;
282 $process['table_page'] = 0;
283 }
284
285 if ( $process['table'] >= count( $process['tables'] ) ) {
286 global $wpdb;
287 if ( in_array( $wpdb->options, $process['tables'], true ) ) {
288
289 // Process 'siteurl' now.
290 $siteurl_value = get_option( 'siteurl' );
291 $case_insensitive = boolval( $process['case_insensitive'] );
292 $replaced_siteurl = $this->run_replace( $process['search'], $process['replace'], $siteurl_value, $case_insensitive );
293
294 if ( $siteurl_value !== $replaced_siteurl ) {
295 // Update the 'siteurl' option.
296 if ( ! $process['dry_run'] ) {
297 update_option( 'siteurl', $replaced_siteurl );
298 }
299
300 $highlighted_results = $this->highlight_replacements( $process['search'], $process['replace'], $siteurl_value, $replaced_siteurl, $case_insensitive );
301
302 $option = $wpdb->get_row( $wpdb->prepare( "SELECT option_id FROM {$wpdb->options} WHERE option_name = %s", 'siteurl' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery
303 $option_id = $option ? $option->option_id : null;
304
305 $operation_data = array(
306 'table' => $wpdb->options,
307 'column' => 'option_value',
308 'row' => $option_id, // Use the option_id here.
309 'old' => $highlighted_results['old'],
310 'new' => $highlighted_results['new'],
311 );
312
313 $updated_data[] = $operation_data;
314
315 do_action( 'wsrw_performed_search_replace', $process, $siteurl_value, $operation_data );
316 }
317 }
318
319 // Search and replace process is complete; delete the process data.
320 delete_option( 'wsrw_process' );
321
322 // Send the final success response.
323 wp_send_json_success(
324 array(
325 'updated_data' => $updated_data,
326 'page' => $process['page'],
327 'pages' => $process['pages'],
328 'message' => esc_html__( 'Search and replace completed successfully.', 'search-replace-wpcode' ),
329 'complete' => true,
330 )
331 );
332 } else {
333 // Update the process data.
334 update_option( 'wsrw_process', $process );
335
336 // Send the partial success response.
337 wp_send_json_success(
338 array(
339 'updated_data' => $updated_data,
340 'page' => $process['page'],
341 'table_page' => $table_page,
342 'table' => $table,
343 'pages' => $process['pages'],
344 // translators: %1$s is the table name %2$s is current database pagination number %3$s is total database pagination pages number.
345 'message' => sprintf( esc_html__( 'Processing table %1$s %2$s/%3$s', 'search-replace-wpcode' ), $table_name, $process['page'], $process['pages'] ),
346 'complete' => false,
347 )
348 );
349 }
350 }
351
352 /**
353 * Highlight the search results.
354 *
355 * @param string $search The search term.
356 * @param string $replace The replace term.
357 * @param string $content The content to search in.
358 * @param string $replaced_content The content after the replace.
359 * @param bool $case_insensitive Whether the search is case insensitive.
360 *
361 * @return array
362 */
363 public function highlight_replacements( $search, $replace, $content, $replaced_content, $case_insensitive ) {
364
365 $old = $this->highlight_results( $search, esc_html( $content ), 'red', array(), '', $case_insensitive );
366 $new = $this->highlight_results( $replace, esc_html( $replaced_content ), 'green', $old['positions'], $search );
367
368 return array(
369 'old' => $old['highlighted'],
370 'new' => $new['highlighted'],
371 );
372 }
373
374 public function reverse_replace( $original_value, $search_string, $replace_string ) {
375 // Replace the $replace_string with the $search_string in the original_value.
376 $modified_value = str_replace( $original_value, $search_string, $replace_string );
377
378 return $modified_value;
379 }
380
381 /**
382 * Run the search and replace.
383 *
384 * @param string $search The search term.
385 * @param string $replace The replace term.
386 * @param string $content The content to search in.
387 * @param bool $case_insensitive Whether the search is case insensitive.
388 *
389 * @return array|mixed|string|string[]
390 */
391 public function run_replace( $search, $replace, $content, $case_insensitive = false ) {
392 // Let's run a search and replace while supporting serialized data.
393 $replaced_content = $content;
394 if ( is_serialized( $content ) ) {
395 $replaced_content = $this->maybe_unserialize( $content );
396 if ( is_array( $replaced_content ) ) {
397 $replaced_content = $this->array_replace_recursive( $search, $replace, $replaced_content, $case_insensitive );
398 } elseif ( is_object( $replaced_content ) ) {
399 $_tmp = clone $replaced_content;
400 $keys = get_object_vars( $replaced_content );
401 foreach ( $keys as $key => $value ) {
402 if ( is_int( $key ) ) {
403 continue;
404 }
405 if ( is_string( $key ) && strpos( $key, "\0" ) !== false ) {
406 continue;
407 }
408 $_tmp->$key = $this->run_replace( $search, $replace, $value, $case_insensitive );
409 }
410 $replaced_content = $_tmp;
411 unset( $_tmp );
412 } else {
413 $replaced_content = $this->str_replace( $search, $replace, $replaced_content, $case_insensitive );
414 }
415 // We need this to be serialized as we got it serialized.
416 $replaced_content = serialize( $replaced_content ); // phpcs:ignore
417 } elseif ( is_string( $content ) ) {
418 $replaced_content = $this->str_replace( $search, $replace, $content, $case_insensitive );
419 }
420
421 return $replaced_content;
422 }
423
424 /**
425 * Recursively replace values in an array.
426 *
427 * @param mixed $search The search term.
428 * @param mixed $replace The replace term.
429 * @param mixed $subject The content to search in.
430 * @param bool $case_insensitive Whether the search is case insensitive.
431 *
432 * @return array|mixed
433 */
434 public function array_replace_recursive( $search, $replace, $subject, $case_insensitive = false ) {
435 if ( is_array( $subject ) ) {
436 foreach ( $subject as $key => $value ) {
437 $subject[ $key ] = $this->array_replace_recursive( $search, $replace, $value );
438 }
439 } elseif ( is_object( $subject ) ) {
440 $_tmp = clone $subject;
441 $keys = get_object_vars( $subject );
442 foreach ( $keys as $key => $value ) {
443 if ( is_int( $key ) ) {
444 continue;
445 }
446 if ( is_string( $key ) && strpos( $key, "\0" ) !== false ) {
447 continue;
448 }
449 $_tmp->$key = $this->array_replace_recursive( $search, $replace, $value );
450 }
451 $subject = $_tmp;
452 unset( $_tmp );
453 } elseif ( is_string( $subject ) ) {
454 $subject = $this->str_replace( $search, $replace, $subject, $case_insensitive );
455 }
456
457 return $subject;
458 }
459
460 /**
461 * Local version of str_replace.
462 *
463 * @param string $search The search term.
464 * @param string $replace The replace term.
465 * @param string $subject The content to search in.
466 * @param bool $case_insensitive Whether the search is case insensitive.
467 *
468 * @return array|mixed|string|string[]
469 */
470 public function str_replace( $search, $replace, $subject, $case_insensitive = false ) {
471 if ( $case_insensitive ) {
472 return str_ireplace( $search, $replace, $subject );
473 }
474
475 return str_replace( $search, $replace, $subject );
476 }
477
478 /**
479 * Get the columns of a table.
480 *
481 * @param string $table_name The name of the table.
482 *
483 * @return array
484 */
485 public static function get_table_columns( $table_name ) {
486 global $wpdb;
487
488 $primary_key = false;
489 $column_names = array();
490 $table_name = esc_sql( $table_name ); // We can't use prepare with %i since we support older versions of WordPress.
491 $columns = $wpdb->get_results( "DESCRIBE $table_name" ); // phpcs:ignore
492
493 foreach ( $columns as $column ) {
494 if ( 'PRI' === $column->Key ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
495 $primary_key = $column->Field; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
496 }
497 $column_names[] = $column->Field; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
498 }
499
500 return array(
501 'primary_key' => $primary_key,
502 'columns' => $column_names,
503 );
504 }
505
506 /**
507 * Calculate the number of pages we need to process.
508 *
509 * @param array $tables The tables to search in.
510 *
511 * @return int
512 */
513 protected function get_all_pages( $tables ) {
514 // Let's get the total number of rows in the tables.
515 $total_rows = 0;
516 foreach ( $tables as $table ) {
517 $total_rows += $this->get_table_pages( $table );
518 }
519
520 return $total_rows;
521 }
522
523 /**
524 * Get the number of pages for a table.
525 *
526 * @param string $table The table name.
527 *
528 * @return int
529 */
530 private function get_table_pages( $table ) {
531 global $wpdb;
532
533 $rows = absint( $wpdb->get_var( "SELECT COUNT(*) FROM $table" ) ); // phpcs:ignore
534
535 if ( 0 === $rows ) {
536 $rows = 1; // We need at least 1 page for each table.
537 }
538
539 return ceil( $rows / $this->get_page_size() );
540 }
541
542 /**
543 * Get the page size.
544 *
545 * @return int
546 */
547 public function get_page_size() {
548 return $this->page_size;
549 }
550
551 /**
552 * Get all the tables in the database.
553 *
554 * @return array
555 */
556 public static function get_all_tables() {
557 global $wpdb;
558
559 $all_tables = $wpdb->get_results( "SHOW TABLES", ARRAY_N ); // phpcs:ignore
560
561 $table_names = array();
562
563 foreach ( $all_tables as $table ) {
564 if ( empty( $table[0] ) ) {
565 continue;
566 }
567 if ( strpos( $table[0], 'wsrw_' ) !== false ) {
568 continue;
569 }
570 $table_names[] = $table[0];
571 }
572
573 // Allow other plugins to exclude their tables here.
574 $table_names = apply_filters( 'wsrw_get_all_tables', $table_names );
575
576 return $table_names;
577 }
578
579 /**
580 * Go through a list of tables as passed from the admin and validate them against the actual database.
581 * Defaults to all tables for now.
582 *
583 * @param array $tables Array of table names to include in the search.
584 *
585 * @return array A validated list of tables.
586 */
587 public function validate_tables( $tables ) {
588 // Let's get a list of all the actual tables in the database.
589 $valid_tables = self::get_all_tables();
590
591 // If we don't have any tables, we can't do anything.
592 if ( empty( $valid_tables ) ) {
593 return array();
594 }
595
596 $validated_tables = array();
597 foreach ( $tables as $table ) {
598 if ( in_array( $table, $valid_tables, true ) ) {
599 $validated_tables[] = $table;
600 }
601 }
602
603 return $validated_tables;
604 }
605
606 /**
607 * Unserialize method that makes sure we set allowed_classes to false.
608 *
609 * @param mixed $data The data to unserialize.
610 *
611 * @return mixed
612 */
613 public function maybe_unserialize( $data ) {
614 if ( is_serialized( $data ) ) {
615 return @unserialize( trim( $data ), array( // phpcs:ignore
616 'allowed_classes' => false,
617 ) ); // phpcs:ignore
618 }
619
620 return $data;
621 }
622
623 /**
624 * Local version of mysql_real_escape_string.
625 *
626 * @param mixed $string The string to escape.
627 *
628 * @return array|mixed|string|string[]
629 */
630 public function mysql_real_escape_string( $string ) {
631 if ( is_array( $string ) ) {
632 return array_map( __METHOD__, $string );
633 }
634 if ( ! empty( $string ) && is_string( $string ) ) {
635 return str_replace(
636 array( '\\', "\0", "\n", "\r", "'", '"', "\x1a" ),
637 array(
638 '\\\\',
639 '\\0',
640 '\\n',
641 '\\r',
642 "\\'",
643 '\\"',
644 '\\Z',
645 ),
646 $string
647 );
648 }
649
650 return $string;
651 }
652 }
653