redirects.conf */ final class Command { /** * List redirects. * * ## OPTIONS * * [--search=] * : Only redirects whose addresses, group or note contain this. * * [--view=] * : all, enabled, disabled, auto, unused or broken. * --- * default: all * --- * * [--limit=] * : How many to show. * --- * default: 50 * --- * * [--format=] * : table, csv, json or count. * --- * default: table * --- * * @subcommand list * @param list $args * @param array $assoc */ public function list_( array $args, array $assoc ): void { $result = ( new Repository() )->find( [ 'search' => (string) ( $assoc['search'] ?? '' ), 'view' => (string) ( $assoc['view'] ?? 'all' ), 'per_page' => (int) ( $assoc['limit'] ?? 50 ), ] ); if ( 'count' === ( $assoc['format'] ?? '' ) ) { WP_CLI::line( (string) $result['total'] ); return; } $rows = array_map( static fn( Rule $r ): array => [ 'id' => $r->id, 'from' => $r->source, 'to' => $r->moves() ? ( 'url' === $r->target_type ? $r->target : $r->target_label() . ' (' . $r->target_type . ' ' . $r->target_id . ')' ) : '—', 'code' => $r->status_code, 'match' => $r->match_type, 'enabled' => $r->enabled ? 'yes' : 'no', 'hits' => $r->hits, 'origin' => $r->origin, ], $result['items'] ); WP_CLI\Utils\format_items( (string) ( $assoc['format'] ?? 'table' ), $rows, [ 'id', 'from', 'to', 'code', 'match', 'enabled', 'hits', 'origin' ] ); } /** * Add a redirect. * * ## OPTIONS * * * : The old address. Use * for "anything". * * [] * : Where to send visitors. Not needed with --code=410 or 451. * * [--code=] * : 301, 302, 307, 308, 410 or 451. * --- * default: 301 * --- * * [--match=] * : exact, wildcard or regex. A * in makes it a wildcard on its own. * --- * default: exact * --- * * [--group=] * : A group to file it under. * * [--note=] * : A note to yourself. * * @param list $args * @param array $assoc */ public function add( array $args, array $assoc ): void { $result = ( new Manager() )->create( [ 'source' => $args[0], 'target' => $args[1] ?? '', 'status_code' => (int) ( $assoc['code'] ?? 301 ), 'match_type' => (string) ( $assoc['match'] ?? 'exact' ), 'group_name' => (string) ( $assoc['group'] ?? '' ), 'note' => (string) ( $assoc['note'] ?? '' ), ] ); if ( is_wp_error( $result ) ) { WP_CLI::error( $result->get_error_message() ); } foreach ( $result['warnings'] as $warning ) { WP_CLI::warning( (string) $warning['message'] ); } WP_CLI::success( sprintf( 'Added redirect %d: %s → %s', $result['rule']->id, $result['rule']->source, $result['rule']->moves() ? $result['rule']->target : $result['rule']->status_code ) ); } /** * Delete redirects by id. * * ## OPTIONS * * ... * : One or more redirect ids. * * @param list $args */ public function delete( array $args ): void { $deleted = ( new Manager() )->delete( array_map( 'intval', $args ) ); WP_CLI::success( sprintf( 'Deleted %d redirect(s).', count( $deleted ) ) ); } /** * Show what would happen to a visitor at an address. * * ## OPTIONS * * * : An address on this site. * * @param list $args */ public function test( array $args ): void { $address = Address::parse( $args[0] ); $trace = []; $outcome = ( new Engine() )->resolve( $address, Validator::neutral(), 'early', $trace ); if ( null === $outcome ) { WP_CLI::line( sprintf( '%s: no redirect applies.', $address->relative() ) ); return; } if ( $outcome->loop ) { WP_CLI::warning( 'These rules lead in a circle, so nothing is done.' ); } foreach ( $outcome->hits as $i => $hit ) { WP_CLI::line( sprintf( '%d. rule %d (%s %s) → %s', $i + 1, $hit->rule->id, $hit->rule->match_type, $hit->rule->source, (string) $hit->url ) ); } if ( null === $outcome->url && ! $outcome->loop ) { WP_CLI::line( sprintf( 'Answers %d in place.', $outcome->code ) ); return; } if ( null !== $outcome->url ) { WP_CLI::success( sprintf( '%d → %s', $outcome->code, $outcome->url ) ); } } /** * Import redirects from a CSV, JSON or .htaccess/Nginx file. * * ## OPTIONS * * * : Path to the file. * * [--group=] * : File everything imported under this group. * * [--dry-run] * : Read the file and report, without importing. * * @param list $args * @param array $assoc */ public function import( array $args, array $assoc ): void { $file = $args[0]; if ( ! is_readable( $file ) ) { WP_CLI::error( "Cannot read {$file}." ); } $importer = new Importer(); $parsed = $importer->read( (string) file_get_contents( $file ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- a local file named on the command line. $this->report_import( $importer, $parsed, $assoc ); } /** * Import redirects left by another plugin. * * ## OPTIONS * * [] * : redirection, rankmath, yoast, srm or simple301. Leave out to list what was found. * * [--group=] * : File everything imported under this group. * * [--dry-run] * : Report without importing. * * @subcommand import-from * @param list $args * @param array $assoc */ public function import_from( array $args, array $assoc ): void { $sources = new Sources(); if ( [] === $args ) { $found = $sources->detect(); if ( [] === $found ) { WP_CLI::line( 'No redirects from other plugins were found.' ); return; } WP_CLI\Utils\format_items( 'table', $found, [ 'key', 'label', 'count' ] ); return; } if ( ! array_key_exists( $args[0], $sources->labels() ) ) { WP_CLI::error( 'Unknown plugin. Choose one of: ' . implode( ', ', array_keys( $sources->labels() ) ) ); } $importer = new Importer(); $this->report_import( $importer, $importer->read_source( $args[0] ), $assoc ); } /** * @param array{rows: list}>, errors: list} $parsed */ private function report_import( Importer $importer, array $parsed, array $assoc ): void { foreach ( $parsed['errors'] as $error ) { WP_CLI::warning( sprintf( 'Line %d: %s', $error['line'], $error['message'] ) ); } WP_CLI::line( sprintf( 'Read %d redirect(s).', count( $parsed['rows'] ) ) ); if ( ! empty( $assoc['dry-run'] ) ) { return; } $progress = WP_CLI\Utils\make_progress_bar( 'Importing', count( $parsed['rows'] ) ); $totals = [ 'added' => 0, 'existing' => 0, 'invalid' => [] ]; foreach ( array_chunk( $parsed['rows'], Importer::BATCH ) as $chunk ) { $report = $importer->import( $chunk, (string) ( $assoc['group'] ?? '' ) ); $totals['added'] += $report['added']; $totals['existing'] += $report['existing']; $totals['invalid'] = array_merge( $totals['invalid'], $report['invalid'] ); $progress->tick( count( $chunk ) ); } $progress->finish(); foreach ( $totals['invalid'] as $invalid ) { WP_CLI::warning( sprintf( 'Line %d (%s): %s', $invalid['line'], $invalid['source'], $invalid['message'] ) ); } WP_CLI::success( sprintf( 'Added %d, already here %d, refused %d.', $totals['added'], $totals['existing'], count( $totals['invalid'] ) ) ); } /** * Export redirects. * * ## OPTIONS * * [--format=] * : csv, json, apache or nginx. * --- * default: csv * --- * * [--group=] * : Only this group. * * @param list $args * @param array $assoc */ public function export( array $args, array $assoc ): void { $format = (string) ( $assoc['format'] ?? 'csv' ); if ( ! in_array( $format, Exporter::FORMATS, true ) ) { WP_CLI::error( 'Format must be one of: ' . implode( ', ', Exporter::FORMATS ) ); } $export = ( new Exporter() )->export( $format, (string) ( $assoc['group'] ?? '' ) ); echo $export['content']; // phpcs:ignore WordPress.Security.EscapeOutput -- written to a terminal or a file, not a page. } /** * List the most-requested pages that were not found. * * ## OPTIONS * * [--limit=] * : How many to show. * --- * default: 20 * --- * * [--format=] * : table, csv or json. * --- * default: table * --- * * @subcommand 404s * @param list $args * @param array $assoc */ public function not_found( array $args, array $assoc ): void { $result = ( new NotFoundLog() )->find( [ 'per_page' => (int) ( $assoc['limit'] ?? 20 ) ] ); WP_CLI\Utils\format_items( (string) ( $assoc['format'] ?? 'table' ), $result['items'], [ 'id', 'url', 'hits', 'last_seen', 'referrer' ] ); } /** * Remove 404s nobody has asked for within the retention period, now. */ public function cleanup(): void { $removed = ( new Cleanup() )->run(); WP_CLI::success( sprintf( 'Removed %d old 404 record(s).', $removed ) ); } }