prepare(). /** * Scans, lists and re-checks links. * * --------------------------------------------------------------------------- * i18n policy: every human-readable message (success, warning, error, log * lines, progress-bar labels) is wrapped in __() with the plugin text domain, * as wordpress.org requires for every user-facing string. * * Machine-readable output is NOT translated, because it is a contract for * scripts: the field names passed to format_items(), the --format values, the * status keywords ('ok', 'broken', ...) and audit check slugs. A translated * field name would silently break every downstream jq filter the day the site * locale changes. * * Strings *returned* by existing plugin methods (the error from * scan_post_now(), the blocked-URL message from recheck_url(), every audit * message, get_error_type_labels()) are already translated and are passed * through verbatim. Do not re-wrap them. * --------------------------------------------------------------------------- * * Authorization note: these commands deliberately do NOT call * nlhealth_current_user_can_read() / _write(). WP-CLI runs with no logged-in user * unless --user= is passed, so a capability check would fail every command in * its default invocation, including read-only ones. The CLI's authorization * boundary is the shell: anyone who can run `wp nlhealth scan` can already run * `wp db query` and read wp-config.php. Do not "fix" this with * wp_set_current_user( 1 ) either — that changes revision authorship in * wp_update_post(), other plugins' save_post handlers, and the rules engine's * apply_action() path in ways the operator never asked for. * * Deliberately does not extend WP_CLI_Command: inheritance is optional in * modern WP-CLI, and extending it would turn any accidental require of this * file outside a CLI context into an instant fatal. A plain class stays inert. */ class NLHealth_CLI { /** * Runs a link scan. * * ## OPTIONS * * [--full] * : Sweep the entire library in chunks instead of running a single * cron-sized batch. Note that the cursor is NOT persisted: an aborted * --full run restarts from offset 0 on the next attempt. * * [--post=] * : Scan one post immediately. * * [--post-type=] * : Comma-separated post types to scan. This REPLACES the configured scan * scope rather than intersecting with it — --post-type=page skips posts * entirely. * * [--chunk-size=] * : Posts per chunk in --full mode. Default 50. A larger chunk means more * HTTP requests inside one PHP process, not less total work. * * [--comments] * : Scan approved comments instead of posts. * * [--menus] * : Scan navigation menu items instead of posts. * * [--force] * : Rescan content even if it was scanned recently. * * ## EXAMPLES * * wp nlhealth scan * wp nlhealth scan --full --chunk-size=25 * wp nlhealth scan --post=42 * wp nlhealth scan --comments * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. * @return void */ public function scan( $args, $assoc_args ): void { $scanner = new NLHealth_Scanner(); $this->apply_post_type_filter( $assoc_args ); if ( isset( $assoc_args['post'] ) ) { $result = $scanner->scan_post_now( (int) $assoc_args['post'] ); if ( isset( $result['error'] ) ) { // Already translated by the scanner — passed through as-is. WP_CLI::error( (string) $result['error'] ); } WP_CLI::success( sprintf( /* translators: %d: post ID. */ __( 'Scanned post %d.', 'linkvane' ), (int) $result['post_id'] ) ); return; } if ( ! empty( $assoc_args['comments'] ) ) { $this->scan_comments( $scanner ); return; } if ( ! empty( $assoc_args['menus'] ) ) { $checked = $scanner->scan_nav_menus( true ); WP_CLI::success( sprintf( /* translators: %d: number of URLs. */ __( 'Checked %d navigation menu URLs.', 'linkvane' ), $checked ) ); return; } if ( ! empty( $assoc_args['full'] ) ) { $this->scan_full( $scanner, (int) ( $assoc_args['chunk-size'] ?? 50 ) ); return; } $scanner->run_batch(); WP_CLI::success( __( 'Ran one scan batch.', 'linkvane' ) ); } /** * Sweeps the whole library, chunk by chunk. * * @param NLHealth_Scanner $scanner Scanner instance. * @param int $chunk Chunk size. * @return void */ private function scan_full( NLHealth_Scanner $scanner, int $chunk ): void { $chunk = max( 1, $chunk ); WP_CLI::warning( __( 'A full scan issues an HTTP request for every URL not already cached. This can take a long time on a large site.', 'linkvane' ) ); $offset = 0; $bar = null; $result = array(); do { // run_full_scan() renews the nlhealth_manual_scan_active transient on // every chunk and clears it on completion. The CLI must not manage // that transient or try to clean it up on abort — letting it expire // is the designed behaviour. $result = $scanner->run_full_scan( $chunk, $offset ); if ( null === $bar ) { $bar = \WP_CLI\Utils\make_progress_bar( __( 'Scanning', 'linkvane' ), (int) $result['total'] ); } // Tick by the DELTA: run_full_scan() returns 'scanned' as a running // total (it is assigned the same value as 'next'), so ticking by it // would overcount threefold. $bar->tick( (int) $result['next'] - $offset ); $offset = (int) $result['next']; } while ( empty( $result['done'] ) ); if ( $bar ) { $bar->finish(); } // run_full_scan() already recomputes PageRank when it completes, so // link-juice scores are fresh without a second command. WP_CLI::success( sprintf( /* translators: %d: number of posts. */ __( 'Full scan complete: %d posts.', 'linkvane' ), $offset ) ); } /** * Drains the comment scan cursor. * * @param NLHealth_Scanner $scanner Scanner instance. * @return void */ private function scan_comments( NLHealth_Scanner $scanner ): void { $approved = (int) wp_count_comments()->approved; // scan_comments() returns 0 AND resets the cursor to 0 when it reaches // the end, so a naive while-loop would restart forever. Cap the // iterations as a second guard on top of the > 0 condition. $max_iterations = (int) ceil( $approved / 20 ) + 1; $total = 0; for ( $i = 0; $i < $max_iterations; $i++ ) { $checked = $scanner->scan_comments( true ); if ( $checked <= 0 ) { break; } $total += $checked; WP_CLI::log( sprintf( /* translators: 1: URLs checked in this pass, 2: running total. */ __( 'Checked %1$d comment URLs (%2$d total).', 'linkvane' ), $checked, $total ) ); } WP_CLI::success( sprintf( /* translators: %d: number of URLs. */ __( 'Checked %d comment URLs.', 'linkvane' ), $total ) ); } /** * Lists recorded broken links. * * ## OPTIONS * * [--broken] * : Accepted as a no-op alias. Every row in the error table is broken by * definition; there is no listing of non-broken links. * * [--source=] * : Filter by source. One of post, comment, menu. * * [--status-code=] * : Filter by HTTP status code. * * [--min-impact=] * : Only rows with an impact score of at least . * * [--group-by=] * : Group the results instead of listing rows. One of domain, error_type. * NOTE: grouping scans at most 1000 rows and retains at most 50 items per * group, so a grouped count is not authoritative on a large site. * * [--page=] * : Page number. Default 1. * * [--per-page=] * : Rows per page. Default 25. * * [--fields=] * : Comma-separated list of fields to display. * * [--format=] * : Render output in a particular format. * --- * default: table * options: * - table * - json * - csv * - yaml * - ids * - count * --- * * ## EXAMPLES * * wp nlhealth list --format=count * wp nlhealth list --source=comment --per-page=10 * wp nlhealth list --group-by=domain * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. * @return void */ public function list( $args, $assoc_args ): void { global $wpdb; $format = (string) ( $assoc_args['format'] ?? 'table' ); $paged = max( 1, (int) ( $assoc_args['page'] ?? 1 ) ); $per_page = max( 1, (int) ( $assoc_args['per-page'] ?? 25 ) ); if ( isset( $assoc_args['group-by'] ) ) { $group_by = sanitize_key( (string) $assoc_args['group-by'] ); if ( ! in_array( $group_by, array( 'domain', 'error_type' ), true ) ) { WP_CLI::error( __( 'Unknown --group-by value. Use domain or error_type.', 'linkvane' ) ); } $groups = ( new NLHealth_Scanner() )->get_grouped_errors( $group_by, $paged, $per_page ); WP_CLI::warning( __( 'Grouped counts scan at most 1000 rows and keep at most 50 items per group.', 'linkvane' ) ); $rows = array(); foreach ( (array) ( $groups['groups'] ?? array() ) as $group ) { $rows[] = array( 'group' => (string) ( $group['group_key'] ?? '' ), 'count' => (int) ( $group['count'] ?? 0 ), ); } \WP_CLI\Utils\format_items( $format, $rows, array( 'group', 'count' ) ); return; } $table = $wpdb->prefix . 'nlhealth_link_errors'; $source = ''; if ( isset( $assoc_args['source'] ) ) { $source = sanitize_key( (string) $assoc_args['source'] ); if ( ! in_array( $source, array( 'post', 'comment', 'menu' ), true ) ) { WP_CLI::error( __( 'Unknown --source value. Use post, comment or menu.', 'linkvane' ) ); } } // -1 and 0 mean "no filter" below; one static WHERE clause covers // every combination of --source, --status-code and --min-impact. $status_code = isset( $assoc_args['status-code'] ) ? (int) $assoc_args['status-code'] : -1; $min_impact = isset( $assoc_args['min-impact'] ) ? max( 0, (int) $assoc_args['min-impact'] ) : 0; if ( 'count' === $format ) { $count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE ( %s = '' OR source_type = %s ) AND ( %d < 0 OR status_code = %d ) AND ( %d = 0 OR impact_score >= %d )", $table, $source, $source, $status_code, $status_code, $min_impact, $min_impact ) ); WP_CLI::line( (string) $count ); return; } $rows = (array) $wpdb->get_results( $wpdb->prepare( "SELECT id, post_id, source_type, raw_url, url_hash, status_code, error_message, impact_score, discovered_at, last_checked_at FROM %i WHERE ( %s = '' OR source_type = %s ) AND ( %d < 0 OR status_code = %d ) AND ( %d = 0 OR impact_score >= %d ) ORDER BY impact_score DESC, last_checked_at DESC LIMIT %d OFFSET %d", $table, $source, $source, $status_code, $status_code, $min_impact, $min_impact, $per_page, ( $paged - 1 ) * $per_page ), ARRAY_A ); if ( 'ids' === $format ) { WP_CLI::line( implode( ' ', wp_list_pluck( $rows, 'id' ) ) ); return; } // Derive the error_type bucket through the scanner's own public // classifier so the CLI and the dashboard always agree. Note the // by-reference loop runs over $rows itself, not a (array) cast of it: // the cast produces a temporary, and the writes would be discarded. $scanner = new NLHealth_Scanner(); $labels = $scanner->get_error_type_labels(); foreach ( $rows as &$row ) { $type = $scanner->classify_error_type( (int) $row['status_code'], (string) $row['error_message'] ); $row['error_type'] = (string) ( $labels[ $type ] ?? $type ); } unset( $row ); $fields = isset( $assoc_args['fields'] ) ? array_map( 'trim', explode( ',', (string) $assoc_args['fields'] ) ) : array( 'id', 'post_id', 'source_type', 'raw_url', 'status_code', 'error_type', 'impact_score' ); // Exit code stays 0 even with results: a listing is not a failure. \WP_CLI\Utils\format_items( $format, (array) $rows, $fields ); } /** * Re-checks a URL immediately. * * Note that this WRITES: like the dashboard button it drives, it updates or * creates the error record and logs lifecycle events. * * Exit code is 0 even when the URL is confirmed broken — a confirmed * breakage is a successful check, not a command failure. * * ## OPTIONS * * * : The URL to re-check. * * [--post=] * : Check the URL in the context of this post. Required to probe a URL * that is not already on record. * * [--all] * : Re-check every post-sourced row for this URL. * * [--format=] * : Render output in a particular format. * --- * default: table * options: * - table * - json * --- * * ## EXAMPLES * * wp nlhealth recheck https://example.com/gone * wp nlhealth recheck https://example.com/gone --all * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. * @return void */ public function recheck( $args, $assoc_args ): void { global $wpdb; $url = (string) ( $args[0] ?? '' ); if ( '' === $url ) { WP_CLI::error( __( 'A URL is required.', 'linkvane' ) ); } $scanner = new NLHealth_Scanner(); $format = (string) ( $assoc_args['format'] ?? 'table' ); $hash = md5( esc_url_raw( $url ) ); $table = $wpdb->prefix . 'nlhealth_link_errors'; $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT id, post_id, source_type FROM %i WHERE url_hash = %s', $table, $hash ) ); $targets = array(); if ( isset( $assoc_args['post'] ) ) { $post_id = (int) $assoc_args['post']; $record_id = 0; foreach ( (array) $rows as $row ) { if ( (int) $row->post_id === $post_id && 'post' === $row->source_type ) { $record_id = (int) $row->id; break; } } $targets[] = array( 'post_id' => $post_id, 'record_id' => $record_id, ); } else { $post_rows = array_filter( (array) $rows, static fn( $row ) => 'post' === $row->source_type ); $skipped = count( (array) $rows ) - count( $post_rows ); if ( $skipped > 0 ) { WP_CLI::warning( sprintf( /* translators: %d: number of rows. */ __( 'Skipping %d comment/menu row(s): re-checking works on post rows only.', 'linkvane' ), $skipped ) ); } if ( empty( $post_rows ) ) { WP_CLI::error( __( 'URL not on record; pass --post= to probe it anyway.', 'linkvane' ) ); } if ( count( $post_rows ) > 1 && empty( $assoc_args['all'] ) ) { $ids = implode( ', ', array_map( static fn( $row ) => (int) $row->post_id, $post_rows ) ); WP_CLI::error( sprintf( /* translators: %s: comma-separated post IDs. */ __( 'URL is recorded on several posts (%s). Pass --post= or --all.', 'linkvane' ), $ids ) ); } if ( count( $post_rows ) > 1 ) { WP_CLI::warning( sprintf( /* translators: %d: number of rows. */ __( 'Re-checking %d rows; each one issues an HTTP request.', 'linkvane' ), count( $post_rows ) ) ); } foreach ( $post_rows as $row ) { $targets[] = array( 'post_id' => (int) $row->post_id, 'record_id' => (int) $row->id, ); } } $bar = count( $targets ) > 1 ? \WP_CLI\Utils\make_progress_bar( __( 'Re-checking', 'linkvane' ), count( $targets ) ) : null; $output = array(); foreach ( $targets as $target ) { $result = $scanner->recheck_url( $url, $target['post_id'], $target['record_id'] ); $output[] = array( 'url' => $url, 'post_id' => $target['post_id'], 'status' => (string) ( $result['status'] ?? '' ), 'status_code' => (int) ( $result['status_code'] ?? 0 ), // Already translated by the scanner — passed through as-is. 'error' => (string) ( $result['error'] ?? '' ), ); if ( $bar ) { $bar->tick(); } } if ( $bar ) { $bar->finish(); } \WP_CLI\Utils\format_items( $format, $output, array( 'url', 'post_id', 'status', 'status_code', 'error' ) ); if ( 1 === count( $output ) ) { if ( 'ok' === $output[0]['status'] ) { WP_CLI::success( __( 'URL is reachable.', 'linkvane' ) ); } else { WP_CLI::warning( sprintf( /* translators: %s: status keyword. */ __( 'URL reported "%s".', 'linkvane' ), $output[0]['status'] ) ); } } } /** * Runs the technical SEO audit. * * ## OPTIONS * * [--check=] * : Comma-separated audit slugs to run. Runs all of them by default. * * [--limit=] * : Cap on posts scanned for redirect chains (default 50). Maps to the * nlhealth_redirect_chain_scan_limit filter. * * [--max-items=] * : Cap on items retained per check (default 50). Maps to the * nlhealth_seo_audit_max_items filter — raise it so --format=json carries * complete lists. * * [--skip-cache] * : Do not write the dashboard's cached audit results. (Named --skip-cache * rather than --no-cache because WP-CLI rewrites a --no- flag into * =false and would then reject it as an unknown parameter.) * * [--format=] * : Render output in a particular format. * --- * default: table * options: * - table * - json * - csv * - yaml * --- * * ## EXAMPLES * * wp nlhealth audit * wp nlhealth audit --check=title_length --skip-cache * wp nlhealth audit --max-items=1000 --format=json * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. * @return void */ public function audit( $args, $assoc_args ): void { $checks = NLHealth_SEO_Audit::get_checks(); $all = $checks; if ( isset( $assoc_args['check'] ) ) { $requested = array_map( 'sanitize_key', array_map( 'trim', explode( ',', (string) $assoc_args['check'] ) ) ); foreach ( $requested as $slug ) { if ( ! isset( $checks[ $slug ] ) ) { WP_CLI::error( sprintf( /* translators: 1: requested check, 2: comma-separated valid checks. */ __( 'Unknown check: %1$s. Valid checks: %2$s', 'linkvane' ), $slug, implode( ', ', array_keys( $checks ) ) ) ); } } $checks = array_intersect_key( $checks, array_flip( $requested ) ); } if ( isset( $assoc_args['limit'] ) ) { $limit = max( 1, (int) $assoc_args['limit'] ); add_filter( 'nlhealth_redirect_chain_scan_limit', static fn() => $limit, 99 ); } if ( isset( $assoc_args['max-items'] ) ) { $max = max( 1, (int) $assoc_args['max-items'] ); add_filter( 'nlhealth_seo_audit_max_items', static fn() => $max, 99 ); } $audit = new NLHealth_SEO_Audit(); $results = array(); // No progress bar on purpose: the twelve checks have wildly different // costs (redirect chains makes HTTP requests, title length does not), // so a bar would be actively misleading. get_public_posts() memoizes, // so running all twelve in one process is one post query, not twelve. foreach ( $checks as $slug => $method ) { WP_CLI::log( sprintf( /* translators: %s: audit check slug. */ __( 'Running %s…', 'linkvane' ), $slug ) ); $results[ $slug ] = $audit->$method(); } // Only refresh the dashboard cache when the FULL set ran. Caching a // partial run would blank the other eleven cards on the dashboard. if ( empty( $assoc_args['skip-cache'] ) && count( $checks ) === count( $all ) ) { set_transient( 'nlhealth_seo_audit_results', $results, DAY_IN_SECONDS ); } $format = (string) ( $assoc_args['format'] ?? 'table' ); if ( 'json' === $format || 'yaml' === $format ) { // Emit the full structure, items included. WP_CLI::print_value( $results, array( 'format' => $format ) ); return; } $rows = array(); foreach ( $results as $slug => $result ) { $rows[] = array( 'check' => $slug, 'status' => (string) ( $result['status'] ?? '' ), 'count' => (int) ( $result['count'] ?? 0 ), // Already translated by the audit — passed through as-is. 'message' => (string) ( $result['message'] ?? '' ), ); } \WP_CLI\Utils\format_items( $format, $rows, array( 'check', 'status', 'count', 'message' ) ); } /** * Prints scanner status. * * ## OPTIONS * * [--format=] * : Render output in a particular format. * --- * default: table * options: * - table * - json * - yaml * --- * * ## EXAMPLES * * wp nlhealth status * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. * @return void */ public function status( $args, $assoc_args ): void { global $wpdb; $scanner = new NLHealth_Scanner(); $metrics = (array) get_option( 'nlhealth_scan_metrics', array() ); $next = wp_next_scheduled( 'nlhealth_run_batch' ); $table = $wpdb->prefix . 'nlhealth_link_errors'; $error_rows = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) ); $scope = (array) get_option( 'nlhealth_scan_scope', array() ); $fields = array( 'plugin_version' => NLHEALTH_VERSION, 'db_version_stored' => (string) get_option( 'nlhealth_db_version', '0' ), 'db_version_code' => NLHEALTH_DB_VERSION, 'schedule' => NLHealth_Activator::get_scan_schedule(), 'next_run' => $next ? gmdate( 'Y-m-d H:i:s', (int) $next ) . ' UTC' : __( 'not scheduled', 'linkvane' ), 'post_types' => implode( ',', $scanner->get_scan_post_types() ), 'scan_scope' => implode( ',', array_keys( array_filter( $scope ) ) ), 'error_rows' => $error_rows, 'manual_scan_active' => get_transient( 'nlhealth_manual_scan_active' ) ? 'yes' : 'no', 'total_urls_checked' => (int) ( $metrics['total_urls_checked'] ?? 0 ), 'posts_scanned' => (int) ( $metrics['posts_scanned'] ?? 0 ), ); $format = (string) ( $assoc_args['format'] ?? 'table' ); if ( 'table' === $format ) { $rows = array(); foreach ( $fields as $key => $value ) { $rows[] = array( 'field' => $key, 'value' => (string) $value, ); } \WP_CLI\Utils\format_items( 'table', $rows, array( 'field', 'value' ) ); return; } WP_CLI::print_value( $fields, array( 'format' => $format ) ); } /** * Narrows the scan to specific post types via the public filter. * * The scanner entry points run_full_scan(), run_batch() and scan_post_now() * all read get_scan_post_types(), which ends in apply_filters(), so the * scope is set from outside rather than by widening any method signature. * * @param array $assoc_args Associative arguments. * @return void */ private function apply_post_type_filter( array $assoc_args ): void { if ( ! isset( $assoc_args['post-type'] ) ) { return; } $types = array_map( 'sanitize_key', array_map( 'trim', explode( ',', (string) $assoc_args['post-type'] ) ) ); $types = array_values( array_filter( $types ) ); foreach ( $types as $type ) { if ( ! post_type_exists( $type ) ) { WP_CLI::error( sprintf( /* translators: %s: post type. */ __( 'Unknown post type: %s', 'linkvane' ), $type ) ); } } WP_CLI::warning( __( '--post-type REPLACES the configured scan scope; other post types are skipped entirely.', 'linkvane' ) ); // Priority 99 outranks a typical site filter but still lets a // PHP_INT_MAX one win. add_filter( 'nlhealth_scan_post_types', static fn() => $types, 99 ); } } /** * Link Juice (internal link graph) commands. * * A separate class because WP-CLI expresses subcommand nesting * (`wp nlhealth juice recalculate`) by registering 'nlhealth juice' as its own command, * not through nested methods. Keeping all three command classes in this one * file preserves the plugin's flat-require convention (this is the 13th and * only conditional require) — hence the sniff exclusion. * * phpcs:disable Generic.Files.OneObjectStructurePerFile.MultipleFound */ class NLHealth_CLI_Juice { /** * Recomputes PageRank scores. * * ## OPTIONS * * [--rebuild] * : Rebuild the whole internal link map first. Entirely offline (no HTTP), * so it is safe and fast even on large sites — this is the cure for a * stale map after a bulk import. * * ## EXAMPLES * * wp nlhealth juice recalculate --rebuild * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. * @return void */ public function recalculate( $args, $assoc_args ): void { $graph = new NLHealth_Link_Graph(); $start = microtime( true ); if ( ! empty( $assoc_args['rebuild'] ) ) { // rebuild_all() ends by calling compute_pagerank() itself and // returns its {nodes, edges} — recomputing here would just repeat // the work. WP_CLI::log( __( 'Rebuilding the internal link map…', 'linkvane' ) ); $result = $graph->rebuild_all(); } else { $result = $graph->compute_pagerank(); } $elapsed = round( microtime( true ) - $start, 2 ); WP_CLI::success( sprintf( /* translators: 1: number of nodes, 2: number of edges, 3: seconds. */ __( 'PageRank computed: %1$d nodes, %2$d edges in %3$ss.', 'linkvane' ), (int) ( $result['nodes'] ?? 0 ), (int) ( $result['edges'] ?? 0 ), $elapsed ) ); } /** * Lists link-juice scores. * * ## OPTIONS * * [--filter=] * : Which rows to list. * --- * default: all * options: * - all * - orphan * - dead_end * - diluted * --- * * [--page=] * : Page number. Default 1. * * [--per-page=] * : Rows per page. Default 25. * * [--format=] * : Render output in a particular format. * --- * default: table * options: * - table * - json * - csv * - yaml * --- * * ## EXAMPLES * * wp nlhealth juice report --filter=orphan * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. * @return void */ public function report( $args, $assoc_args ): void { $report = ( new NLHealth_Link_Graph() )->get_report( array( 'filter' => (string) ( $assoc_args['filter'] ?? 'all' ), 'paged' => max( 1, (int) ( $assoc_args['page'] ?? 1 ) ), 'per_page' => max( 1, (int) ( $assoc_args['per-page'] ?? 25 ) ), ) ); $rows = array(); foreach ( (array) ( $report['rows'] ?? array() ) as $row ) { $rows[] = (array) $row; } if ( empty( $rows ) ) { WP_CLI::success( __( 'No rows matched.', 'linkvane' ) ); return; } \WP_CLI\Utils\format_items( (string) ( $assoc_args['format'] ?? 'table' ), $rows, array_keys( $rows[0] ) ); } /** * Prints link-graph summary counters. * * ## OPTIONS * * [--format=] * : Render output in a particular format. * --- * default: table * options: * - table * - json * - yaml * --- * * ## EXAMPLES * * wp nlhealth juice status * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. * @return void */ public function status( $args, $assoc_args ): void { $graph = new NLHealth_Link_Graph(); $summary = $graph->get_summary(); $summary['health_score'] = $graph->calculate_health_score(); // Exposed so a CI job can assert the map is fresh. $summary['dirty'] = NLHealth_Link_Graph::is_dirty() ? 'yes' : 'no'; $format = (string) ( $assoc_args['format'] ?? 'table' ); if ( 'table' === $format ) { $rows = array(); foreach ( $summary as $key => $value ) { $rows[] = array( 'field' => (string) $key, 'value' => is_scalar( $value ) ? (string) $value : wp_json_encode( $value ), ); } \WP_CLI\Utils\format_items( 'table', $rows, array( 'field', 'value' ) ); return; } WP_CLI::print_value( $summary, array( 'format' => $format ) ); } } /** * Schema commands. */ class NLHealth_CLI_DB { /** * Applies any pending schema migration. * * ## EXAMPLES * * wp nlhealth db upgrade * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. * @return void */ public function upgrade( $args, $assoc_args ): void { NLHealth_Activator::maybe_upgrade(); WP_CLI::success( sprintf( /* translators: %s: schema version. */ __( 'Schema is at %s.', 'linkvane' ), (string) get_option( 'nlhealth_db_version', '0' ) ) ); } /** * Prints the stored schema version against the one the code expects. * * Exits with code 1 when they differ, so CI can gate on it. * * ## EXAMPLES * * wp nlhealth db version * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. * @return void */ public function version( $args, $assoc_args ): void { $stored = (string) get_option( 'nlhealth_db_version', '0' ); /* translators: %s: schema version. */ WP_CLI::log( sprintf( __( 'Stored: %s', 'linkvane' ), $stored ) ); /* translators: %s: schema version. */ WP_CLI::log( sprintf( __( 'Expected: %s', 'linkvane' ), NLHEALTH_DB_VERSION ) ); if ( NLHEALTH_DB_VERSION !== $stored ) { WP_CLI::error( __( 'Schema is out of date. Run: wp nlhealth db upgrade', 'linkvane' ) ); } WP_CLI::success( __( 'Schema is up to date.', 'linkvane' ) ); } }