add_action( 'abds_seo_audit_batch', $this, 'audit_batch', 10, 2 ); } /** * Reads one run's own progress/result row — the shape the R3 Audit * screen's live-progress poll (D4) reads every ~2s while a run is active. * * @param int $run_id wp_abds_seo_audit_runs row ID. * @return array{id:int, status:string, trigger_type:string, urls_total:int, urls_completed:int, urls_failed:int, issues_found:int, avg_score:?float, started_at:?string, finished_at:?string}|null Null if the run doesn't exist. */ public function get_run_status( int $run_id ): ?array { global $wpdb; $runs_table = $wpdb->prefix . 'abds_seo_audit_runs'; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $runs_table is built only from $wpdb->prefix plus a hardcoded literal, never user input; $run_id is passed through $wpdb->prepare(). Polled at a ~2s interval only while a run's own screen is open and visible (D4), not a high-traffic path. $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$runs_table} WHERE id = %d", $run_id ) ); if ( ! $row ) { return null; } return array( 'id' => (int) $row->id, 'status' => (string) $row->status, 'trigger_type' => (string) $row->trigger_type, 'urls_total' => (int) $row->urls_total, 'urls_completed' => (int) $row->urls_completed, 'urls_failed' => (int) $row->urls_failed, 'issues_found' => (int) $row->issues_found, 'avg_score' => null !== $row->avg_score ? (float) $row->avg_score : null, 'started_at' => $row->started_at, 'finished_at' => $row->finished_at, ); } /** * Reads one run's issues, grouped by issue_key — the shape the Audit * screen's IssueList (mockup Screen 3) renders: one card per distinct * issue, with the number of affected URLs, not one row per URL. * * @param int $run_id wp_abds_seo_audit_runs row ID. * @return array */ public function get_run_issues( int $run_id ): array { global $wpdb; $issues_table = $wpdb->prefix . 'abds_seo_audit_issues'; $sql = 'SELECT issue_key, category, severity, MIN(message) AS message, COUNT(DISTINCT audit_url_id) AS affected_count' . " FROM {$issues_table}" . ' WHERE run_id = %d' . ' GROUP BY issue_key, category, severity' . " ORDER BY FIELD(severity, 'critical', 'high', 'medium', 'low', 'notice'), affected_count DESC"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $issues_table is built only from $wpdb->prefix plus a hardcoded literal, never user input; $sql is a fixed literal built just above (no runtime input in it), $run_id is passed through $wpdb->prepare(). Read once when the Audit screen opens a finished run's results, not a high-traffic path. $rows = $wpdb->get_results( $wpdb->prepare( $sql, $run_id ) ); return array_map( function ( $row ): array { return array( 'issue_key' => (string) $row->issue_key, 'category' => (string) $row->category, 'severity' => (string) $row->severity, 'message' => (string) $row->message, 'affected_count' => (int) $row->affected_count, ); }, $rows ); } /** * M2 1.2: the pages one specific issue_key affects, for the accordion * card's expanded page list. Paginated the same shape * ABDS_SEO_Reports::query() already uses ({items,total,page,per_page, * total_pages}) so the Audit screen's pager component can be reused * as-is. * * @param int $run_id wp_abds_seo_audit_runs row ID. * @param string $issue_key issue_key to filter on. * @param int $page 1-based page number. * @param int $per_page Rows per page, clamped to [1, MAX_PER_PAGE]. * @return array{items:array, total:int, page:int, per_page:int, total_pages:int} */ public function get_issue_urls( int $run_id, string $issue_key, int $page = 1, int $per_page = self::DEFAULT_PER_PAGE ): array { global $wpdb; $urls_table = $wpdb->prefix . 'abds_seo_audit_urls'; $issues_table = $wpdb->prefix . 'abds_seo_audit_issues'; $page = max( 1, $page ); $per_page = max( 1, min( self::MAX_PER_PAGE, $per_page ) ); $offset = ( $page - 1 ) * $per_page; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $urls_table/$issues_table are built only from $wpdb->prefix plus a hardcoded literal, never user input; every value is passed through $wpdb->prepare(). Read once when an issue card is expanded, not a high-traffic path. $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$issues_table} WHERE run_id = %d AND issue_key = %s", $run_id, $issue_key ) ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT u.object_type, u.object_id, u.url, i.message, i.details_json FROM {$issues_table} i INNER JOIN {$urls_table} u ON u.id = i.audit_url_id WHERE i.run_id = %d AND i.issue_key = %s ORDER BY i.id ASC LIMIT %d OFFSET %d", $run_id, $issue_key, $per_page, $offset ) ); // phpcs:enable return array( 'items' => array_map( array( $this, 'format_affected_page' ), $rows ), 'total' => $total, 'page' => $page, 'per_page' => $per_page, 'total_pages' => (int) ceil( $total / max( 1, $per_page ) ), ); } /** * M2 1.2/1.4: every URL this run checked, each with its own issues * embedded ($with_issues) — the "By pages" toggle's data source. * Ordered worst-first (most issues, then lowest score) so the * pages needing the most attention surface at the top. * * @param int $run_id wp_abds_seo_audit_runs row ID. * @param bool $with_issues Whether to embed each URL's own issue list. * @return array */ public function get_run_urls( int $run_id, bool $with_issues = false ): array { global $wpdb; $urls_table = $wpdb->prefix . 'abds_seo_audit_urls'; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $urls_table is built only from $wpdb->prefix plus a hardcoded literal, never user input; $run_id is passed through $wpdb->prepare(). Read once when the Audit screen's "By pages" view opens a finished run, not a high-traffic path. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT object_type, object_id, url, score, issues_count, id FROM {$urls_table} WHERE run_id = %d AND status = 'completed' ORDER BY issues_count DESC, score ASC", $run_id ) ); // phpcs:enable $issues_by_url_id = $with_issues ? $this->group_issues_by_url_id( $run_id ) : array(); return array_map( function ( $row ) use ( $with_issues, $issues_by_url_id ): array { $page = $this->format_page_row( $row ); if ( $with_issues ) { $page['issues'] = $issues_by_url_id[ (int) $row->id ] ?? array(); } return $page; }, $rows ); } /** * M3 2.1: the most recently COMPLETED run's ID — the frontend admin-bar * node and highlight overlay only ever look at finished, scored results, * never a run that's still in progress (a partial run's counts would be * actively misleading on the one page a real editor is looking at). * * @return ?int Null if no run has ever completed. */ public function get_latest_completed_run_id(): ?int { global $wpdb; $runs_table = $wpdb->prefix . 'abds_seo_audit_runs'; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $runs_table is built only from $wpdb->prefix plus a hardcoded literal, never user input; no runtime input in this query at all. Read on admin-bar render (frontend, logged-in editors only, M3 2.1) and once per highlight-mode REST call -- not a high-traffic path. $id = $wpdb->get_var( "SELECT id FROM {$runs_table} WHERE status = 'completed' ORDER BY finished_at DESC LIMIT 1" ); // phpcs:enable return null !== $id ? (int) $id : null; } /** * M3 2.1/2.2: one object's own issues from one run, catalog-enriched the * same way get_run_issues() already is — the admin-bar node needs just * the count, the highlight overlay's REST call needs the full list * (why/how_to_fix/fix_location/locator details). * * @param int $run_id wp_abds_seo_audit_runs row ID. * @param string $object_type Matches wp_abds_seo_audit_urls.object_type (e.g. 'post'). * @param int $object_id Matches wp_abds_seo_audit_urls.object_id. * @return array */ public function get_object_issues( int $run_id, string $object_type, int $object_id ): array { global $wpdb; $urls_table = $wpdb->prefix . 'abds_seo_audit_urls'; $issues_table = $wpdb->prefix . 'abds_seo_audit_issues'; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $urls_table/$issues_table are built only from $wpdb->prefix plus a hardcoded literal, never user input; every value is passed through $wpdb->prepare(). Read on the highlight-mode REST call only (M3 2.2, gated to logged-in editors), not a high-traffic path. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT i.issue_key, i.category, i.severity, i.message, i.details_json FROM {$issues_table} i INNER JOIN {$urls_table} u ON u.id = i.audit_url_id WHERE i.run_id = %d AND u.object_type = %s AND u.object_id = %d ORDER BY FIELD(i.severity, 'critical', 'high', 'medium', 'low', 'notice')", $run_id, $object_type, $object_id ) ); // phpcs:enable return array_map( function ( $row ): array { return array( 'issue_key' => (string) $row->issue_key, 'category' => (string) $row->category, 'severity' => (string) $row->severity, 'message' => (string) $row->message, 'details' => $row->details_json ? (array) json_decode( (string) $row->details_json, true ) : array(), ); }, $rows ); } /** * Every open issue for one run, grouped by its owning audit_url_id — * one query instead of one per URL (N+1) for get_run_urls()'s * $with_issues path. * * @param int $run_id wp_abds_seo_audit_runs row ID. * @return array> */ protected function group_issues_by_url_id( int $run_id ): array { global $wpdb; $issues_table = $wpdb->prefix . 'abds_seo_audit_issues'; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $issues_table is built only from $wpdb->prefix plus a hardcoded literal, never user input; $run_id is passed through $wpdb->prepare(). Read once when the Audit screen's "By pages" view opens a finished run, not a high-traffic path. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT audit_url_id, issue_key, category, severity, message, details_json FROM {$issues_table} WHERE run_id = %d ORDER BY FIELD(severity, 'critical', 'high', 'medium', 'low', 'notice')", $run_id ) ); // phpcs:enable $grouped = array(); foreach ( $rows as $row ) { $grouped[ (int) $row->audit_url_id ][] = array( 'issue_key' => (string) $row->issue_key, 'category' => (string) $row->category, 'severity' => (string) $row->severity, 'message' => (string) $row->message, 'details' => $row->details_json ? (array) json_decode( (string) $row->details_json, true ) : array(), ); } return $grouped; } /** * Builds one affected-page entry for get_issue_urls() — title/edit_url * resolved from object_id the same way format_page_row() does, plus * this issue's own details_json. * * @param object $row DB row: object_type, object_id, url, details_json. * @return array{url:string, title:string, object_type:string, object_id:int, edit_url:string, details:array} */ protected function format_affected_page( $row ): array { $object_id = (int) $row->object_id; return array( 'url' => (string) $row->url, 'title' => 'post' === $row->object_type ? (string) get_the_title( $object_id ) : '', 'object_type' => (string) $row->object_type, 'object_id' => $object_id, 'edit_url' => 'post' === $row->object_type ? (string) get_edit_post_link( $object_id, 'raw' ) : '', // This page's OWN instance of the message -- may carry a count/ // detail the group header (ABDS_SEO_Issue_Catalog's title) never // does, e.g. "2 images without an alt attribute." vs. a sibling // page's "1 image...". The group header itself must never show // this (M2 visual acceptance, 2026-09-22): one page's own count // contradicting the group's own "N pages" count read as a bug. 'message' => (string) $row->message, 'details' => $row->details_json ? (array) json_decode( (string) $row->details_json, true ) : array(), ); } /** * Builds one URL entry (without issues) for get_run_urls(). * * @param object $row DB row: object_type, object_id, url, score, issues_count, id. * @return array{url:string, title:string, object_type:string, object_id:int, edit_url:string, score:?float, issues_count:int} */ protected function format_page_row( $row ): array { $object_id = (int) $row->object_id; return array( 'url' => (string) $row->url, 'title' => 'post' === $row->object_type ? (string) get_the_title( $object_id ) : '', 'object_type' => (string) $row->object_type, 'object_id' => $object_id, 'edit_url' => 'post' === $row->object_type ? (string) get_edit_post_link( $object_id, 'raw' ) : '', 'score' => null !== $row->score ? (float) $row->score : null, 'issues_count' => (int) $row->issues_count, ); } /** * Starts a new audit run: creates the wp_abds_seo_audit_runs row, * pre-creates one wp_abds_seo_audit_urls row per published post * (status='pending'), and schedules one throttled Action Scheduler * batch per BATCH_SIZE URLs. Public/synchronous (unlike * ABDS_SEO_Scan_Scheduler::run_site_scan(), which is itself the cron/ * action-hooked entry point) — this is called directly by the REST * controller/admin-post handler that starts a run, so its return value * (the new run_id) can be handed straight back to the caller for the * live-progress poll (D4) to start against immediately. * * @param string $trigger_type 'manual' or 'cli' — never 'recurring' in 0.12.0 (D4). * @param int $initiated_by User ID who started this run, 0 for CLI/system. * @return int The new wp_abds_seo_audit_runs row ID, or 0 if Action Scheduler is unavailable. */ public function start_audit_run( string $trigger_type = 'manual', int $initiated_by = 0 ): int { if ( ! function_exists( 'as_schedule_single_action' ) ) { return 0; // Action Scheduler not available; caller surfaces this rather than silently doing nothing. } global $wpdb; $post_ids = get_posts( array( 'post_type' => 'any', 'post_status' => 'publish', 'posts_per_page' => -1, // phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_posts_per_page -- internal background batch: collects every published post's ID once, same as ABDS_SEO_Scan_Scheduler::run_site_scan(). 'fields' => 'ids', ) ); $runs_table = $wpdb->prefix . 'abds_seo_audit_runs'; $now = current_time( 'mysql' ); $has_urls = ! empty( $post_ids ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $runs_table is built only from $wpdb->prefix plus a hardcoded literal, never user input; $wpdb->insert() safely escapes its values via the format array. Runs once per manually-triggered audit, not a cacheable read path. $wpdb->insert( $runs_table, array( 'status' => $has_urls ? 'running' : 'completed', 'trigger_type' => $trigger_type, 'started_at' => $now, 'finished_at' => $has_urls ? null : $now, 'urls_total' => count( $post_ids ), 'initiated_by' => $initiated_by > 0 ? $initiated_by : null, 'created_at' => $now, ), array( '%s', '%s', '%s', '%s', '%d', '%d', '%s' ) ); $run_id = (int) $wpdb->insert_id; if ( ! $has_urls ) { return $run_id; } $audit_url_ids = $this->create_pending_urls( $run_id, $post_ids, $now ); $batches = array_chunk( $audit_url_ids, self::BATCH_SIZE ); foreach ( $batches as $index => $batch_ids ) { as_schedule_single_action( time() + ( $index * self::BATCH_THROTTLE_SECONDS ), 'abds_seo_audit_batch', array( $batch_ids, $run_id ), self::GROUP ); } return $run_id; } /** * Pre-creates one pending wp_abds_seo_audit_urls row per post, so every * URL this run will touch has a real row (and ID) before any batch * action runs — the batches below schedule against these IDs, not raw * post IDs, so ABDS_SEO_Audit_Engine::run_check_for_url() always has a * row to read/update. * * @param int $run_id Owning audit run. * @param int[] $post_ids Published post IDs to audit. * @param string $now Shared created_at timestamp for this batch of inserts. * @return int[] The new wp_abds_seo_audit_urls row IDs, same order as $post_ids. */ protected function create_pending_urls( int $run_id, array $post_ids, string $now ): array { global $wpdb; $urls_table = $wpdb->prefix . 'abds_seo_audit_urls'; $audit_url_ids = array(); foreach ( $post_ids as $post_id ) { $post_id = (int) $post_id; $url = (string) get_permalink( $post_id ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $urls_table is built only from $wpdb->prefix plus a hardcoded literal, never user input; $wpdb->insert() safely escapes its values via the format array. Runs once per post in a manually-triggered audit, not a cacheable read path. $wpdb->insert( $urls_table, array( 'run_id' => $run_id, 'object_type' => 'post', 'object_id' => $post_id, 'url' => $url, 'url_hash' => hash( 'sha256', $url ), 'status' => 'pending', 'created_at' => $now, ), array( '%d', '%s', '%d', '%s', '%s', '%s', '%s' ) ); $audit_url_ids[] = (int) $wpdb->insert_id; } return $audit_url_ids; } /** * Runs one batch: checks every URL in it, then updates the run's own * completed/failed counters and finishes the run once every URL has * been accounted for. * * @param int[] $audit_url_ids wp_abds_seo_audit_urls row IDs in this batch. * @param int $run_id Owning wp_abds_seo_audit_runs row ID. */ public function audit_batch( array $audit_url_ids, int $run_id ): void { $engine = new ABDS_SEO_Audit_Engine(); foreach ( $audit_url_ids as $audit_url_id ) { $engine->run_check_for_url( (int) $audit_url_id ); } global $wpdb; $urls_table = $wpdb->prefix . 'abds_seo_audit_urls'; $runs_table = $wpdb->prefix . 'abds_seo_audit_runs'; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $urls_table/$runs_table are built only from $wpdb->prefix plus a hardcoded literal, never user input; values are passed through $wpdb->prepare(). Runs once per background batch, not on a cacheable read path. $completed = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$urls_table} WHERE run_id = %d AND status = 'completed'", $run_id ) ); $failed = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$urls_table} WHERE run_id = %d AND status = 'failed'", $run_id ) ); $wpdb->update( $runs_table, array( 'urls_completed' => $completed, 'urls_failed' => $failed, ), array( 'id' => $run_id ), array( '%d', '%d' ), array( '%d' ) ); $urls_total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT urls_total FROM {$runs_table} WHERE id = %d", $run_id ) ); // phpcs:enable if ( $completed + $failed >= $urls_total ) { $this->finish_audit_run( $run_id ); } } /** * Marks a run completed and aggregates its own avg_score/issues_found — * scoped to THIS run_id via the run_id column on audit_urls/audit_issues, * deliberately not repeating ABDS_SEO_Scan_Scheduler::finish_scan()'s * known bug of averaging over the entire page_scores table regardless of * which scan actually ran (see this plan's own risk-file note). * * @param int $run_id wp_abds_seo_audit_runs row ID. */ protected function finish_audit_run( int $run_id ): void { global $wpdb; $runs_table = $wpdb->prefix . 'abds_seo_audit_runs'; $urls_table = $wpdb->prefix . 'abds_seo_audit_urls'; $issues_table = $wpdb->prefix . 'abds_seo_audit_issues'; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $urls_table/$issues_table/$runs_table are built only from $wpdb->prefix plus a hardcoded literal, never user input; values are passed through $wpdb->prepare()/the update() format array. Runs once per completed run, not on a cacheable read path. $avg_score = $wpdb->get_var( $wpdb->prepare( "SELECT AVG(score) FROM {$urls_table} WHERE run_id = %d AND status = 'completed'", $run_id ) ); $issues_found = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$issues_table} WHERE run_id = %d", $run_id ) ); $wpdb->update( $runs_table, array( 'status' => 'completed', 'finished_at' => current_time( 'mysql' ), 'avg_score' => $avg_score, 'issues_found' => $issues_found, ), array( 'id' => $run_id ), array( '%s', '%s', '%f', '%d' ), array( '%d' ) ); // phpcs:enable } }