class-search-regex-cli.php
514 lines
| 1 | <?php |
| 2 | |
| 3 | namespace SearchRegex\Cli; |
| 4 | |
| 5 | use SearchRegex\Source; |
| 6 | use SearchRegex\Search; |
| 7 | use SearchRegex\Filter; |
| 8 | use SearchRegex\Action; |
| 9 | use SearchRegex\Schema; |
| 10 | use WP_CLI; |
| 11 | use WP_Error; |
| 12 | |
| 13 | /** |
| 14 | * Search Regex WP CLI commands |
| 15 | */ |
| 16 | class Search_Regex_CLI { |
| 17 | /** |
| 18 | * Perform a search using Search Regex |
| 19 | * |
| 20 | * ## OPTIONS |
| 21 | * |
| 22 | * <search> |
| 23 | * : The search phrase to find |
| 24 | * |
| 25 | * [--source=<source>] |
| 26 | * : The source to search in. Available: posts, comment, user, options, post-meta, comment-meta, user-meta, terms |
| 27 | * --- |
| 28 | * default: posts |
| 29 | * --- |
| 30 | * |
| 31 | * [--regex] |
| 32 | * : Use regular expression search |
| 33 | * |
| 34 | * [--case-insensitive] |
| 35 | * : Perform case-insensitive search |
| 36 | * |
| 37 | * [--per-page=<number>] |
| 38 | * : Number of results per page |
| 39 | * --- |
| 40 | * default: 25 |
| 41 | * --- |
| 42 | * |
| 43 | * [--page=<number>] |
| 44 | * : Page number to retrieve |
| 45 | * --- |
| 46 | * default: 0 |
| 47 | * --- |
| 48 | * |
| 49 | * [--limit=<number>] |
| 50 | * : Maximum number of results to return (0 for unlimited) |
| 51 | * --- |
| 52 | * default: 0 |
| 53 | * --- |
| 54 | * |
| 55 | * [--format=<format>] |
| 56 | * : Output format |
| 57 | * --- |
| 58 | * default: table |
| 59 | * options: |
| 60 | * - table |
| 61 | * - json |
| 62 | * - csv |
| 63 | * - yaml |
| 64 | * - count |
| 65 | * --- |
| 66 | * |
| 67 | * ## EXAMPLES |
| 68 | * |
| 69 | * # Search for "hello world" in posts |
| 70 | * $ wp search-regex search "hello world" |
| 71 | * |
| 72 | * # Search for a pattern using regex in comments |
| 73 | * $ wp search-regex search "email.*@.*\.com" --source=comment --regex |
| 74 | * |
| 75 | * # Case-insensitive search in options |
| 76 | * $ wp search-regex search "siteurl" --source=options --case-insensitive |
| 77 | * |
| 78 | * # Get JSON output |
| 79 | * $ wp search-regex search "test" --format=json |
| 80 | * |
| 81 | * # Get just the count of matches |
| 82 | * $ wp search-regex search "test" --format=count |
| 83 | * |
| 84 | * @param array<string> $args Positional arguments. |
| 85 | * @param array<string, mixed> $assoc_args Associative arguments. |
| 86 | * @return void |
| 87 | * @when after_wp_load |
| 88 | */ |
| 89 | public function search( $args, $assoc_args ): void { |
| 90 | $search_phrase = $args[0]; |
| 91 | $source = $assoc_args['source'] ?? 'posts'; |
| 92 | $per_page = isset( $assoc_args['per-page'] ) ? intval( $assoc_args['per-page'] ) : 25; |
| 93 | $page = isset( $assoc_args['page'] ) ? intval( $assoc_args['page'] ) : 0; |
| 94 | $limit = isset( $assoc_args['limit'] ) ? intval( $assoc_args['limit'] ) : 0; |
| 95 | $format = $assoc_args['format'] ?? 'table'; |
| 96 | |
| 97 | // Build search flags |
| 98 | $search_flags = []; |
| 99 | if ( isset( $assoc_args['regex'] ) ) { |
| 100 | $search_flags[] = 'regex'; |
| 101 | } |
| 102 | if ( isset( $assoc_args['case-insensitive'] ) ) { |
| 103 | $search_flags[] = 'case'; |
| 104 | } |
| 105 | |
| 106 | // Validate source |
| 107 | $allowed_sources = Source\Manager::get_all_source_names(); |
| 108 | if ( ! in_array( $source, $allowed_sources, true ) ) { |
| 109 | WP_CLI::error( sprintf( 'Invalid source "%s". Available sources: %s', $source, implode( ', ', $allowed_sources ) ) ); |
| 110 | } |
| 111 | |
| 112 | WP_CLI::line( sprintf( 'Searching for "%s" in %s...', $search_phrase, $source ) ); |
| 113 | |
| 114 | // Perform the search |
| 115 | $params = [ |
| 116 | 'searchPhrase' => $search_phrase, |
| 117 | 'source' => [ $source ], |
| 118 | 'searchFlags' => $search_flags, |
| 119 | 'page' => $page, |
| 120 | 'perPage' => $per_page, |
| 121 | 'limit' => $limit, |
| 122 | 'action' => 'nothing', |
| 123 | 'actionOption' => [], |
| 124 | ]; |
| 125 | |
| 126 | try { |
| 127 | $schema = new Schema\Schema( Source\Manager::get_schema( $params['source'] ) ); |
| 128 | $filters = []; |
| 129 | |
| 130 | // Add global search filter |
| 131 | if ( $params['searchPhrase'] !== '' ) { |
| 132 | $filters[] = new Filter\Global_Filter( $params['searchPhrase'], $params['searchFlags'] ); |
| 133 | } |
| 134 | |
| 135 | // Create the action |
| 136 | $action = Action\Action::create( 'nothing', [], $schema ); |
| 137 | |
| 138 | // Get sources |
| 139 | $sources = Source\Manager::get( $params['source'], $filters ); |
| 140 | |
| 141 | // Create the search |
| 142 | $search = new Search\Search( $sources ); |
| 143 | |
| 144 | // Execute search |
| 145 | $results = $search->get_search_results( $action, $params['page'], $params['perPage'], $params['limit'] ); |
| 146 | |
| 147 | if ( is_wp_error( $results ) ) { |
| 148 | WP_CLI::error( $results->get_error_message() ); |
| 149 | return; |
| 150 | } |
| 151 | |
| 152 | $result_data = $action->get_results( $results ); |
| 153 | |
| 154 | // Handle format |
| 155 | if ( $format === 'count' ) { |
| 156 | WP_CLI::line( sprintf( 'Found %d results', count( $result_data['results'] ) ) ); |
| 157 | return; |
| 158 | } |
| 159 | |
| 160 | if ( count( $result_data['results'] ) === 0 ) { |
| 161 | WP_CLI::success( 'No results found.' ); |
| 162 | return; |
| 163 | } |
| 164 | |
| 165 | // Format results for output |
| 166 | $output_items = []; |
| 167 | foreach ( $result_data['results'] as $result ) { |
| 168 | $match_count = 0; |
| 169 | $columns_text = []; |
| 170 | $matched_texts = $this->extract_matched_texts( $result ); |
| 171 | |
| 172 | if ( isset( $result['columns'] ) ) { |
| 173 | foreach ( $result['columns'] as $column ) { |
| 174 | $match_count += isset( $column['match_count'] ) ? intval( $column['match_count'] ) : 0; |
| 175 | $columns_text[] = $column['column_label']; |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | $output_items[] = [ |
| 180 | 'row_id' => $result['row_id'], |
| 181 | 'source' => $result['source_name'], |
| 182 | 'title' => $result['title'] ?? '', |
| 183 | 'matches' => $match_count, |
| 184 | 'columns' => implode( ', ', $columns_text ), |
| 185 | 'matched_text' => implode( ' | ', array_unique( $matched_texts ) ), |
| 186 | ]; |
| 187 | } |
| 188 | |
| 189 | // Output results |
| 190 | if ( $format === 'json' ) { |
| 191 | $json_output = json_encode( $result_data, JSON_PRETTY_PRINT ); |
| 192 | WP_CLI::line( $json_output !== false ? $json_output : '{}' ); |
| 193 | } else { |
| 194 | WP_CLI\Utils\format_items( $format, $output_items, [ 'row_id', 'source', 'title', 'matches', 'columns', 'matched_text' ] ); |
| 195 | } |
| 196 | |
| 197 | // Show totals |
| 198 | if ( isset( $result_data['totals'] ) ) { |
| 199 | $totals = $result_data['totals']; |
| 200 | WP_CLI::line( sprintf( |
| 201 | "\nShowing %d results. Total rows in source: %d", |
| 202 | count( $result_data['results'] ), |
| 203 | $totals['rows'] |
| 204 | ) ); |
| 205 | } |
| 206 | } catch ( \Exception $e ) { |
| 207 | WP_CLI::error( 'Search failed: ' . $e->getMessage() ); |
| 208 | } |
| 209 | } |
| 210 | |
| 211 | /** |
| 212 | * Extract matched text from result data |
| 213 | * |
| 214 | * @param array<string, mixed> $result Result data. |
| 215 | * @return string[] Array of matched text strings. |
| 216 | */ |
| 217 | private function extract_matched_texts( $result ) { |
| 218 | $matched_texts = []; |
| 219 | |
| 220 | if ( isset( $result['columns'] ) ) { |
| 221 | foreach ( $result['columns'] as $column ) { |
| 222 | // Extract actual matched text from contexts |
| 223 | if ( isset( $column['contexts'] ) ) { |
| 224 | foreach ( $column['contexts'] as $context ) { |
| 225 | if ( isset( $context['matches'] ) ) { |
| 226 | foreach ( $context['matches'] as $match ) { |
| 227 | if ( isset( $match['match'] ) ) { |
| 228 | $matched_text = $match['match']; |
| 229 | // Truncate if too long for table display |
| 230 | if ( strlen( $matched_text ) > 50 ) { |
| 231 | $matched_text = substr( $matched_text, 0, 47 ) . '...'; |
| 232 | } |
| 233 | $matched_texts[] = $matched_text; |
| 234 | } |
| 235 | } |
| 236 | } |
| 237 | } |
| 238 | } |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | return $matched_texts; |
| 243 | } |
| 244 | |
| 245 | /** |
| 246 | * Perform a search and replace using Search Regex |
| 247 | * |
| 248 | * ## OPTIONS |
| 249 | * |
| 250 | * <search> |
| 251 | * : The search phrase to find |
| 252 | * |
| 253 | * <replace> |
| 254 | * : The replacement text |
| 255 | * |
| 256 | * [--source=<source>] |
| 257 | * : The source to search in. Available: posts, comment, user, options, post-meta, comment-meta, user-meta, terms |
| 258 | * --- |
| 259 | * default: posts |
| 260 | * --- |
| 261 | * |
| 262 | * [--regex] |
| 263 | * : Use regular expression search |
| 264 | * |
| 265 | * [--case-insensitive] |
| 266 | * : Perform case-insensitive search |
| 267 | * |
| 268 | * [--per-page=<number>] |
| 269 | * : Number of results per page to process |
| 270 | * --- |
| 271 | * default: 25 |
| 272 | * --- |
| 273 | * |
| 274 | * [--limit=<number>] |
| 275 | * : Maximum number of results to replace (0 for unlimited) |
| 276 | * --- |
| 277 | * default: 0 |
| 278 | * --- |
| 279 | * |
| 280 | * [--dry-run] |
| 281 | * : Show what would be replaced without actually saving changes |
| 282 | * |
| 283 | * [--format=<format>] |
| 284 | * : Output format |
| 285 | * --- |
| 286 | * default: table |
| 287 | * options: |
| 288 | * - table |
| 289 | * - json |
| 290 | * - csv |
| 291 | * - yaml |
| 292 | * - count |
| 293 | * --- |
| 294 | * |
| 295 | * ## EXAMPLES |
| 296 | * |
| 297 | * # Replace "hello world" with "hi everyone" in posts (dry run) |
| 298 | * $ wp search-regex replace "hello world" "hi everyone" --dry-run |
| 299 | * |
| 300 | * # Replace using regex in comments |
| 301 | * $ wp search-regex replace "email@.*\.com" "email@example.com" --source=comment --regex |
| 302 | * |
| 303 | * # Case-insensitive replace in options and save changes |
| 304 | * $ wp search-regex replace "siteurl" "site_url" --source=options --case-insensitive |
| 305 | * |
| 306 | * # Replace with a limit |
| 307 | * $ wp search-regex replace "test" "production" --limit=10 |
| 308 | * |
| 309 | * @param array<string> $args Positional arguments. |
| 310 | * @param array<string, mixed> $assoc_args Associative arguments. |
| 311 | * @return void |
| 312 | * @when after_wp_load |
| 313 | */ |
| 314 | public function replace( $args, $assoc_args ): void { |
| 315 | if ( count( $args ) < 2 ) { |
| 316 | WP_CLI::error( 'Both search and replace arguments are required.' ); |
| 317 | } |
| 318 | |
| 319 | $search_phrase = $args[0]; |
| 320 | $replace_phrase = $args[1]; |
| 321 | $source = $assoc_args['source'] ?? 'posts'; |
| 322 | $per_page = isset( $assoc_args['per-page'] ) ? intval( $assoc_args['per-page'] ) : 25; |
| 323 | $limit = isset( $assoc_args['limit'] ) ? intval( $assoc_args['limit'] ) : 0; |
| 324 | $format = $assoc_args['format'] ?? 'table'; |
| 325 | $dry_run = isset( $assoc_args['dry-run'] ); |
| 326 | |
| 327 | // Build search flags |
| 328 | $search_flags = []; |
| 329 | if ( isset( $assoc_args['regex'] ) ) { |
| 330 | $search_flags[] = 'regex'; |
| 331 | } |
| 332 | if ( isset( $assoc_args['case-insensitive'] ) ) { |
| 333 | $search_flags[] = 'case'; |
| 334 | } |
| 335 | |
| 336 | // Validate source |
| 337 | $allowed_sources = Source\Manager::get_all_source_names(); |
| 338 | if ( ! in_array( $source, $allowed_sources, true ) ) { |
| 339 | WP_CLI::error( sprintf( 'Invalid source "%s". Available sources: %s', $source, implode( ', ', $allowed_sources ) ) ); |
| 340 | } |
| 341 | |
| 342 | if ( $dry_run ) { |
| 343 | WP_CLI::line( sprintf( 'DRY RUN: Searching for "%s" to replace with "%s" in %s...', $search_phrase, $replace_phrase, $source ) ); |
| 344 | } else { |
| 345 | WP_CLI::line( sprintf( 'Replacing "%s" with "%s" in %s...', $search_phrase, $replace_phrase, $source ) ); |
| 346 | } |
| 347 | |
| 348 | // Perform the search and replace |
| 349 | $params = [ |
| 350 | 'searchPhrase' => $search_phrase, |
| 351 | 'replacement' => $replace_phrase, |
| 352 | 'source' => [ $source ], |
| 353 | 'searchFlags' => $search_flags, |
| 354 | 'page' => 0, |
| 355 | 'perPage' => $per_page, |
| 356 | 'limit' => $limit, |
| 357 | 'action' => 'replace', |
| 358 | 'actionOption' => [], |
| 359 | ]; |
| 360 | |
| 361 | try { |
| 362 | $schema = new Schema\Schema( Source\Manager::get_schema( $params['source'] ) ); |
| 363 | $filters = []; |
| 364 | |
| 365 | // Add global search filter |
| 366 | if ( $params['searchPhrase'] !== '' ) { |
| 367 | $filters[] = new Filter\Global_Filter( $params['searchPhrase'], $params['searchFlags'] ); |
| 368 | } |
| 369 | |
| 370 | // Create the action |
| 371 | $action = Action\Action::create( |
| 372 | 'replace', |
| 373 | [ |
| 374 | 'search' => $params['searchPhrase'], |
| 375 | 'replacement' => $params['replacement'], |
| 376 | 'flags' => $params['searchFlags'], |
| 377 | ], |
| 378 | $schema |
| 379 | ); |
| 380 | |
| 381 | // Set save mode based on dry-run flag |
| 382 | if ( ! $dry_run ) { |
| 383 | $action->set_save_mode( true ); |
| 384 | } |
| 385 | |
| 386 | // Get sources |
| 387 | $sources = Source\Manager::get( $params['source'], $filters ); |
| 388 | |
| 389 | // Create the search |
| 390 | $search = new Search\Search( $sources ); |
| 391 | |
| 392 | // Execute search and replace |
| 393 | $results = $search->get_search_results( $action, $params['page'], $params['perPage'], $params['limit'] ); |
| 394 | |
| 395 | if ( is_wp_error( $results ) ) { |
| 396 | WP_CLI::error( $results->get_error_message() ); |
| 397 | return; |
| 398 | } |
| 399 | |
| 400 | $result_data = $action->get_results( $results ); |
| 401 | |
| 402 | // Handle format |
| 403 | if ( $format === 'count' ) { |
| 404 | WP_CLI::line( sprintf( '%s %d results', $dry_run ? 'Would replace' : 'Replaced', count( $result_data['results'] ) ) ); |
| 405 | return; |
| 406 | } |
| 407 | |
| 408 | if ( count( $result_data['results'] ) === 0 ) { |
| 409 | WP_CLI::success( 'No results found.' ); |
| 410 | return; |
| 411 | } |
| 412 | |
| 413 | // Format results for output |
| 414 | $output_items = []; |
| 415 | foreach ( $result_data['results'] as $result ) { |
| 416 | $match_count = 0; |
| 417 | $columns_text = []; |
| 418 | $matched_texts = $this->extract_matched_texts( $result ); |
| 419 | |
| 420 | if ( isset( $result['columns'] ) ) { |
| 421 | foreach ( $result['columns'] as $column ) { |
| 422 | $match_count += isset( $column['match_count'] ) ? intval( $column['match_count'] ) : 0; |
| 423 | $columns_text[] = $column['column_label']; |
| 424 | } |
| 425 | } |
| 426 | |
| 427 | $output_items[] = [ |
| 428 | 'row_id' => $result['row_id'], |
| 429 | 'source' => $result['source_name'], |
| 430 | 'title' => $result['title'] ?? '', |
| 431 | 'matches' => $match_count, |
| 432 | 'columns' => implode( ', ', $columns_text ), |
| 433 | 'matched_text' => implode( ' | ', array_unique( $matched_texts ) ), |
| 434 | ]; |
| 435 | } |
| 436 | |
| 437 | // Output results |
| 438 | if ( $format === 'json' ) { |
| 439 | $json_output = json_encode( $result_data, JSON_PRETTY_PRINT ); |
| 440 | WP_CLI::line( $json_output !== false ? $json_output : '{}' ); |
| 441 | } else { |
| 442 | WP_CLI\Utils\format_items( $format, $output_items, [ 'row_id', 'source', 'title', 'matches', 'columns', 'matched_text' ] ); |
| 443 | } |
| 444 | |
| 445 | // Show totals |
| 446 | if ( isset( $result_data['totals'] ) ) { |
| 447 | $totals = $result_data['totals']; |
| 448 | $status_message = sprintf( |
| 449 | "\n%s %d results. Total rows in source: %d", |
| 450 | $dry_run ? 'Would replace' : 'Replaced', |
| 451 | count( $result_data['results'] ), |
| 452 | $totals['rows'] |
| 453 | ); |
| 454 | |
| 455 | if ( $dry_run ) { |
| 456 | WP_CLI::warning( $status_message . ' (No changes saved - dry run mode)' ); |
| 457 | } else { |
| 458 | WP_CLI::success( $status_message ); |
| 459 | } |
| 460 | } |
| 461 | } catch ( \Exception $e ) { |
| 462 | WP_CLI::error( 'Replace failed: ' . $e->getMessage() ); |
| 463 | } |
| 464 | } |
| 465 | |
| 466 | /** |
| 467 | * List available search sources |
| 468 | * |
| 469 | * ## OPTIONS |
| 470 | * |
| 471 | * [--format=<format>] |
| 472 | * : Output format |
| 473 | * --- |
| 474 | * default: table |
| 475 | * options: |
| 476 | * - table |
| 477 | * - json |
| 478 | * - csv |
| 479 | * - yaml |
| 480 | * --- |
| 481 | * |
| 482 | * ## EXAMPLES |
| 483 | * |
| 484 | * # List all available sources |
| 485 | * $ wp search-regex sources |
| 486 | * |
| 487 | * @param array<string> $_args Positional arguments. |
| 488 | * @param array<string, mixed> $assoc_args Associative arguments. |
| 489 | * @return void |
| 490 | * @when after_wp_load |
| 491 | */ |
| 492 | public function sources( $_args, $assoc_args ): void { |
| 493 | $format = $assoc_args['format'] ?? 'table'; |
| 494 | |
| 495 | $sources = Source\Manager::get_all_sources(); |
| 496 | $output_items = []; |
| 497 | |
| 498 | foreach ( $sources as $source ) { |
| 499 | $output_items[] = [ |
| 500 | 'name' => $source['name'], |
| 501 | 'label' => $source['label'], |
| 502 | 'type' => $source['type'], |
| 503 | ]; |
| 504 | } |
| 505 | |
| 506 | WP_CLI\Utils\format_items( $format, $output_items, [ 'name', 'label', 'type' ] ); |
| 507 | } |
| 508 | } |
| 509 | |
| 510 | // Register the WP CLI command |
| 511 | if ( defined( 'WP_CLI' ) && WP_CLI ) { |
| 512 | WP_CLI::add_command( 'search-regex', \SearchRegex\Cli\Search_Regex_CLI::class ); |
| 513 | } |
| 514 |