From 7547eb8e6324479123244bfab1e4d904982d6171 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:54:07 +0000 Subject: [PATCH 1/6] Initial plan From 11fa9590c79382028d12eeba2df120a2fd88c480 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:08:24 +0000 Subject: [PATCH 2/6] Fix: Handle JSON-encoded URLs in search-replace for WordPress font data Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- features/search-replace.feature | 49 +++++++++++++++++++++++++++++++++ src/Search_Replace_Command.php | 29 ++++++++++++++++++- src/WP_CLI/SearchReplacer.php | 19 +++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) diff --git a/features/search-replace.feature b/features/search-replace.feature index 062a67e0..7524f142 100644 --- a/features/search-replace.feature +++ b/features/search-replace.feature @@ -238,6 +238,55 @@ Feature: Do global search/replace | key | value | | header_image_data | {"url":"https:\/\/example.com\/foo.jpg"} | + @require-mysql + Scenario: Search and replace handles JSON-encoded URLs in post content + Given a WP install + And a create-post-with-json-content.php file: + """ + 'post', + 'post_status' => 'publish', + 'post_title' => 'Font Test', + 'post_content' => json_encode( [ 'src' => 'http://example.com/wp-content/uploads/fonts/test.woff2', 'fontWeight' => '400' ] ), + ] ); + echo $post_id; + """ + And I run `wp eval-file create-post-with-json-content.php` + Then save STDOUT as {POST_ID} + + When I run `wp post get {POST_ID} --field=post_content` + Then STDOUT should contain: + """ + http:\/\/example.com + """ + + When I run `wp search-replace 'http://example.com' 'http://newdomain.com' wp_posts --include-columns=post_content` + Then STDOUT should be a table containing rows: + | Table | Column | Replacements | Type | + | wp_posts | post_content | 1 | SQL | + + When I run `wp post get {POST_ID} --field=post_content` + Then STDOUT should contain: + """ + http:\/\/newdomain.com + """ + And STDOUT should not contain: + """ + http:\/\/example.com + """ + + When I run `wp search-replace 'http://newdomain.com' 'http://example.com' wp_posts --include-columns=post_content --precise` + Then STDOUT should be a table containing rows: + | Table | Column | Replacements | Type | + | wp_posts | post_content | 1 | PHP | + + When I run `wp post get {POST_ID} --field=post_content` + Then STDOUT should contain: + """ + http:\/\/example.com + """ + @require-mysql Scenario: Search and replace with quoted strings Given a WP install diff --git a/src/Search_Replace_Command.php b/src/Search_Replace_Command.php index 520ab8cc..1cf42bc5 100644 --- a/src/Search_Replace_Command.php +++ b/src/Search_Replace_Command.php @@ -652,12 +652,18 @@ private function sql_handle_col( $col, $primary_keys, $table, $old, $new ) { $table_sql = self::esc_sql_ident( $table ); $col_sql = self::esc_sql_ident( $col ); + $old_json = self::json_encode_strip_quotes( $old ); + $new_json = self::json_encode_strip_quotes( $new ); if ( $this->dry_run ) { if ( $this->log_handle ) { $count = $this->log_sql_diff( $col, $primary_keys, $table, $old, $new ); } else { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident $count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT($col_sql) FROM $table_sql WHERE $col_sql LIKE BINARY %s;", '%' . self::esc_like( $old ) . '%' ) ); + if ( $old_json !== $old ) { + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident + $count += (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT($col_sql) FROM $table_sql WHERE $col_sql LIKE BINARY %s;", '%' . self::esc_like( $old_json ) . '%' ) ); + } } } else { if ( $this->log_handle ) { @@ -665,6 +671,10 @@ private function sql_handle_col( $col, $primary_keys, $table, $old, $new ) { } // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident $count = $wpdb->query( $wpdb->prepare( "UPDATE $table_sql SET $col_sql = REPLACE($col_sql, %s, %s);", $old, $new ) ); + if ( $old_json !== $old ) { + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident + $count += (int) $wpdb->query( $wpdb->prepare( "UPDATE $table_sql SET $col_sql = REPLACE($col_sql, %s, %s);", $old_json, $new_json ) ); + } } if ( $this->verbose && 'table' === $this->format ) { @@ -686,8 +696,12 @@ private function php_handle_col( $col, $primary_keys, $table, $old, $new ) { $base_key_condition = ''; $where_key = ''; if ( ! $this->regex ) { + $old_json = self::json_encode_strip_quotes( $old ); $base_key_condition = "$col_sql" . $wpdb->prepare( ' LIKE BINARY %s', '%' . self::esc_like( $old ) . '%' ); - $where_key = "WHERE $base_key_condition"; + if ( $old_json !== $old ) { + $base_key_condition = "( $base_key_condition OR $col_sql" . $wpdb->prepare( ' LIKE BINARY %s', '%' . self::esc_like( $old_json ) . '%' ) . ' )'; + } + $where_key = "WHERE $base_key_condition"; } $escaped_primary_keys = self::esc_sql_ident( $primary_keys ); @@ -917,6 +931,19 @@ private static function esc_like( $old ) { return $old; } + /** + * Returns the JSON-encoded representation of a string with the surrounding quotes stripped. + * This is used to also handle values stored as raw JSON in the database (e.g. WordPress font data). + * Returns the original string unchanged if JSON encoding fails (e.g. invalid UTF-8). + * + * @param string $str The string to encode. + * @return string The JSON-encoded string without surrounding quotes, or the original string on failure. + */ + private static function json_encode_strip_quotes( $str ) { + $encoded = json_encode( $str ); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode + return false !== $encoded ? substr( $encoded, 1, -1 ) : $str; + } + /** * Escapes (backticks) MySQL identifiers (aka schema object names) - i.e. column names, table names, and database/index/alias/view etc names. * See https://dev.mysql.com/doc/refman/5.5/en/identifiers.html diff --git a/src/WP_CLI/SearchReplacer.php b/src/WP_CLI/SearchReplacer.php index 8c5ee951..be8819ba 100644 --- a/src/WP_CLI/SearchReplacer.php +++ b/src/WP_CLI/SearchReplacer.php @@ -17,6 +17,16 @@ class SearchReplacer { */ private $to; + /** + * @var string + */ + private $from_json; + + /** + * @var string + */ + private $to_json; + /** * @var bool */ @@ -78,6 +88,12 @@ public function __construct( $from, $to, $recurse_objects = false, $regex = fals $this->logging = $logging; $this->clear_log_data(); + // Compute JSON-encoded versions (stripping outer quotes) for handling raw JSON values in the database. + $from_encoded = json_encode( $from ); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode + $this->from_json = false !== $from_encoded ? substr( $from_encoded, 1, -1 ) : $from; + $to_encoded = json_encode( $to ); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode + $this->to_json = false !== $to_encoded ? substr( $to_encoded, 1, -1 ) : $to; + // Get the XDebug nesting level. Will be zero (no limit) if no value is set $this->max_recursion = intval( ini_get( 'xdebug.max_nesting_level' ) ); } @@ -204,6 +220,9 @@ private function run_recursively( $data, $serialised, $recursion_level = 0, $vis $data = $result; } else { $data = str_replace( $this->from, $this->to, $data ); + if ( $this->from_json !== $this->from ) { + $data = str_replace( $this->from_json, $this->to_json, $data ); + } } if ( $this->logging && $old_data !== $data ) { $this->log_data[] = $old_data; From d007b9844836a20ad6bf0ceb36cd64a53086a3a4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:30:23 +0000 Subject: [PATCH 3/6] Fix test: use $wpdb->insert() to preserve backslash-escaped JSON in post content Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- features/search-replace.feature | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/features/search-replace.feature b/features/search-replace.feature index 7524f142..618b2163 100644 --- a/features/search-replace.feature +++ b/features/search-replace.feature @@ -244,13 +244,26 @@ Feature: Do global search/replace And a create-post-with-json-content.php file: """ 'post', - 'post_status' => 'publish', - 'post_title' => 'Font Test', - 'post_content' => json_encode( [ 'src' => 'http://example.com/wp-content/uploads/fonts/test.woff2', 'fontWeight' => '400' ] ), - ] ); - echo $post_id; + // Insert directly via $wpdb to preserve backslash-escaped JSON (as WordPress font face data is stored). + global $wpdb; + $wpdb->insert( + $wpdb->posts, + [ + 'post_type' => 'post', + 'post_status' => 'publish', + 'post_title' => 'Font Test', + 'post_content' => '{"src":"http:\/\/example.com\/wp-content\/uploads\/fonts\/test.woff2","fontWeight":"400"}', + 'post_author' => 1, + 'post_date' => '2024-01-01 00:00:00', + 'post_date_gmt' => '2024-01-01 00:00:00', + 'post_modified' => '2024-01-01 00:00:00', + 'post_modified_gmt' => '2024-01-01 00:00:00', + 'to_ping' => '', + 'pinged' => '', + 'post_content_filtered' => '', + ] + ); + echo $wpdb->insert_id; """ And I run `wp eval-file create-post-with-json-content.php` Then save STDOUT as {POST_ID} From 30b90440c73825055f7e5cfd4a4eaf937912a72d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:04:35 +0000 Subject: [PATCH 4/6] Refactor: make json_encode_strip_quotes() public static and reuse from SearchReplacer Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/Search_Replace_Command.php | 2 +- src/WP_CLI/SearchReplacer.php | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/Search_Replace_Command.php b/src/Search_Replace_Command.php index 1cf42bc5..45f3549f 100644 --- a/src/Search_Replace_Command.php +++ b/src/Search_Replace_Command.php @@ -939,7 +939,7 @@ private static function esc_like( $old ) { * @param string $str The string to encode. * @return string The JSON-encoded string without surrounding quotes, or the original string on failure. */ - private static function json_encode_strip_quotes( $str ) { + public static function json_encode_strip_quotes( $str ) { $encoded = json_encode( $str ); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode return false !== $encoded ? substr( $encoded, 1, -1 ) : $str; } diff --git a/src/WP_CLI/SearchReplacer.php b/src/WP_CLI/SearchReplacer.php index be8819ba..6f3bea9f 100644 --- a/src/WP_CLI/SearchReplacer.php +++ b/src/WP_CLI/SearchReplacer.php @@ -89,10 +89,8 @@ public function __construct( $from, $to, $recurse_objects = false, $regex = fals $this->clear_log_data(); // Compute JSON-encoded versions (stripping outer quotes) for handling raw JSON values in the database. - $from_encoded = json_encode( $from ); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode - $this->from_json = false !== $from_encoded ? substr( $from_encoded, 1, -1 ) : $from; - $to_encoded = json_encode( $to ); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode - $this->to_json = false !== $to_encoded ? substr( $to_encoded, 1, -1 ) : $to; + $this->from_json = \Search_Replace_Command::json_encode_strip_quotes( $from ); + $this->to_json = \Search_Replace_Command::json_encode_strip_quotes( $to ); // Get the XDebug nesting level. Will be zero (no limit) if no value is set $this->max_recursion = intval( ini_get( 'xdebug.max_nesting_level' ) ); From ec7f90bb310033ce31587fd4ac00b38be152e20b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:11:42 +0000 Subject: [PATCH 5/6] Fix sql_handle_col: use single OR query for count, nested REPLACE for update, and log JSON variants Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/Search_Replace_Command.php | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/Search_Replace_Command.php b/src/Search_Replace_Command.php index 45f3549f..bbad1bdf 100644 --- a/src/Search_Replace_Command.php +++ b/src/Search_Replace_Command.php @@ -654,26 +654,36 @@ private function sql_handle_col( $col, $primary_keys, $table, $old, $new ) { $col_sql = self::esc_sql_ident( $col ); $old_json = self::json_encode_strip_quotes( $old ); $new_json = self::json_encode_strip_quotes( $new ); + $has_json = $old_json !== $old; + if ( $this->dry_run ) { if ( $this->log_handle ) { $count = $this->log_sql_diff( $col, $primary_keys, $table, $old, $new ); + if ( $has_json ) { + $count += $this->log_sql_diff( $col, $primary_keys, $table, $old_json, $new_json ); + } + } elseif ( $has_json ) { + // Single query with OR to avoid counting rows that match both forms twice. + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident + $count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT($col_sql) FROM $table_sql WHERE $col_sql LIKE BINARY %s OR $col_sql LIKE BINARY %s;", '%' . self::esc_like( $old ) . '%', '%' . self::esc_like( $old_json ) . '%' ) ); } else { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident - $count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT($col_sql) FROM $table_sql WHERE $col_sql LIKE BINARY %s;", '%' . self::esc_like( $old ) . '%' ) ); - if ( $old_json !== $old ) { - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident - $count += (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT($col_sql) FROM $table_sql WHERE $col_sql LIKE BINARY %s;", '%' . self::esc_like( $old_json ) . '%' ) ); - } + $count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT($col_sql) FROM $table_sql WHERE $col_sql LIKE BINARY %s;", '%' . self::esc_like( $old ) . '%' ) ); } } else { if ( $this->log_handle ) { $this->log_sql_diff( $col, $primary_keys, $table, $old, $new ); + if ( $has_json ) { + $this->log_sql_diff( $col, $primary_keys, $table, $old_json, $new_json ); + } } - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident - $count = $wpdb->query( $wpdb->prepare( "UPDATE $table_sql SET $col_sql = REPLACE($col_sql, %s, %s);", $old, $new ) ); - if ( $old_json !== $old ) { + if ( $has_json ) { + // Single nested REPLACE handles both plain and JSON-encoded forms in one pass. + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident + $count = (int) $wpdb->query( $wpdb->prepare( "UPDATE $table_sql SET $col_sql = REPLACE(REPLACE($col_sql, %s, %s), %s, %s);", $old, $new, $old_json, $new_json ) ); + } else { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident - $count += (int) $wpdb->query( $wpdb->prepare( "UPDATE $table_sql SET $col_sql = REPLACE($col_sql, %s, %s);", $old_json, $new_json ) ); + $count = (int) $wpdb->query( $wpdb->prepare( "UPDATE $table_sql SET $col_sql = REPLACE($col_sql, %s, %s);", $old, $new ) ); } } From 50be907fcfea1e120773ba131ef9e85a1055ca77 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:35:34 +0000 Subject: [PATCH 6/6] Use wp post create instead of eval-file+wpdb in JSON URL test Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- features/search-replace.feature | 27 ++------------------------- 1 file changed, 2 insertions(+), 25 deletions(-) diff --git a/features/search-replace.feature b/features/search-replace.feature index 618b2163..d6feee8a 100644 --- a/features/search-replace.feature +++ b/features/search-replace.feature @@ -241,31 +241,8 @@ Feature: Do global search/replace @require-mysql Scenario: Search and replace handles JSON-encoded URLs in post content Given a WP install - And a create-post-with-json-content.php file: - """ - insert( - $wpdb->posts, - [ - 'post_type' => 'post', - 'post_status' => 'publish', - 'post_title' => 'Font Test', - 'post_content' => '{"src":"http:\/\/example.com\/wp-content\/uploads\/fonts\/test.woff2","fontWeight":"400"}', - 'post_author' => 1, - 'post_date' => '2024-01-01 00:00:00', - 'post_date_gmt' => '2024-01-01 00:00:00', - 'post_modified' => '2024-01-01 00:00:00', - 'post_modified_gmt' => '2024-01-01 00:00:00', - 'to_ping' => '', - 'pinged' => '', - 'post_content_filtered' => '', - ] - ); - echo $wpdb->insert_id; - """ - And I run `wp eval-file create-post-with-json-content.php` + + When I run `wp post create --post_content='{"src":"http:\/\/example.com\/wp-content\/uploads\/fonts\/test.woff2","fontWeight":"400"}' --post_status=publish --porcelain` Then save STDOUT as {POST_ID} When I run `wp post get {POST_ID} --field=post_content`