db = $wpdb; $this->scans_table = $wpdb->prefix . 'wpag_scans'; $this->issues_table = $wpdb->prefix . 'wpag_issues'; } /** * Create a new scan row in 'queued' state. * * @return array{id:int,uuid:string} */ public function create( string $url, string $wcag_level = 'AA' ): array { $uuid = wp_generate_uuid4(); $now = current_time( 'mysql', true ); $this->db->insert( $this->scans_table, [ 'scan_uuid' => $uuid, 'url' => $url, 'status' => 'queued', 'wcag_level' => $wcag_level, 'created_at' => $now, ], [ '%s', '%s', '%s', '%s', '%s' ] ); return [ 'id' => (int) $this->db->insert_id, 'uuid' => $uuid, ]; } /** * Persist axe-core results for a scan and mark it completed. * * @param array> $issues */ public function complete( int $scan_id, int $score, array $issues, ?array $raw = null ): void { $now = current_time( 'mysql', true ); $this->db->update( $this->scans_table, [ 'status' => 'completed', 'score' => max( 0, min( 100, $score ) ), 'issues_count' => count( $issues ), 'raw_payload' => $raw ? wp_json_encode( $raw ) : null, 'completed_at' => $now, ], [ 'id' => $scan_id ], [ '%s', '%d', '%d', '%s', '%s' ], [ '%d' ] ); // Replace any previous issues for this scan (defensive). $this->db->delete( $this->issues_table, [ 'scan_id' => $scan_id ], [ '%d' ] ); foreach ( $issues as $issue ) { $this->db->insert( $this->issues_table, [ 'scan_id' => $scan_id, 'rule_id' => substr( (string) ( $issue['rule_id'] ?? '' ), 0, 80 ), 'impact' => $this->normalize_impact( (string) ( $issue['impact'] ?? 'minor' ) ), 'selector' => (string) ( $issue['selector'] ?? '' ), 'html' => isset( $issue['html'] ) ? substr( (string) $issue['html'], 0, 4000 ) : null, 'help_url' => isset( $issue['help_url'] ) ? esc_url_raw( (string) $issue['help_url'] ) : null, ], [ '%d', '%s', '%s', '%s', '%s', '%s' ] ); } $this->update_summary_cache(); } public function fail( int $scan_id, string $reason = '' ): void { $this->db->update( $this->scans_table, [ 'status' => 'failed', 'raw_payload' => wp_json_encode( [ 'error' => $reason ] ), 'completed_at' => current_time( 'mysql', true ), ], [ 'id' => $scan_id ], [ '%s', '%s', '%s' ], [ '%d' ] ); } /** * @return list> */ public function list( int $limit = 20, int $offset = 0 ): array { $limit = max( 1, min( 100, $limit ) ); $offset = max( 0, $offset ); $rows = $this->db->get_results( $this->db->prepare( "SELECT id, scan_uuid, url, status, score, wcag_level, issues_count, created_at, completed_at FROM {$this->scans_table} ORDER BY id DESC LIMIT %d OFFSET %d", $limit, $offset ), ARRAY_A ); return array_map( [ $this, 'map_scan_row' ], $rows ?: [] ); } public function count(): int { return (int) $this->db->get_var( "SELECT COUNT(*) FROM {$this->scans_table}" ); } public function find( int $id ): ?array { $row = $this->db->get_row( $this->db->prepare( "SELECT * FROM {$this->scans_table} WHERE id = %d", $id ), ARRAY_A ); return $row ? $this->map_scan_row( $row ) : null; } /** * @return list> */ public function issues_for( int $scan_id ): array { $rows = $this->db->get_results( $this->db->prepare( "SELECT id, rule_id, impact, selector, html, help_url, fixed FROM {$this->issues_table} WHERE scan_id = %d ORDER BY FIELD(impact,'critical','serious','moderate','minor'), id ASC", $scan_id ), ARRAY_A ); return array_map( static function ( array $row ): array { return [ 'id' => (int) $row['id'], 'ruleId' => $row['rule_id'], 'impact' => $row['impact'], 'selector' => $row['selector'], 'html' => $row['html'], 'helpUrl' => $row['help_url'], 'fixed' => (bool) $row['fixed'], ]; }, $rows ?: [] ); } public function delete( int $id ): bool { // Issues are removed by FK-less cascade via explicit delete. $this->db->delete( $this->issues_table, [ 'scan_id' => $id ], [ '%d' ] ); $result = $this->db->delete( $this->scans_table, [ 'id' => $id ], [ '%d' ] ); $this->update_summary_cache(); return (bool) $result; } public function latest_completed(): ?array { $row = $this->db->get_row( "SELECT id, scan_uuid, url, status, score, issues_count, created_at, completed_at FROM {$this->scans_table} WHERE status = 'completed' ORDER BY id DESC LIMIT 1", ARRAY_A ); return $row ? $this->map_scan_row( $row ) : null; } public function previous_completed_for_url( string $url, int $before_id ): ?array { $row = $this->db->get_row( $this->db->prepare( "SELECT id, scan_uuid, url, status, score, issues_count, created_at, completed_at FROM {$this->scans_table} WHERE status = 'completed' AND url = %s AND id < %d ORDER BY id DESC LIMIT 1", $url, $before_id ), ARRAY_A ); return $row ? $this->map_scan_row( $row ) : null; } /** * Aggregated counts by impact across the latest completed scan. * * @return array */ public function impact_breakdown_for( int $scan_id ): array { $rows = $this->db->get_results( $this->db->prepare( "SELECT impact, COUNT(*) AS c FROM {$this->issues_table} WHERE scan_id = %d GROUP BY impact", $scan_id ), ARRAY_A ) ?: []; $out = [ 'critical' => 0, 'serious' => 0, 'moderate' => 0, 'minor' => 0 ]; foreach ( $rows as $row ) { $out[ $row['impact'] ] = (int) $row['c']; } return $out; } private function update_summary_cache(): void { $latest = $this->latest_completed(); if ( ! $latest ) { delete_option( 'wpag_last_scan_summary' ); return; } update_option( 'wpag_last_scan_summary', [ 'scanId' => $latest['id'], 'url' => $latest['url'], 'score' => $latest['score'], 'issues' => $latest['issuesCount'], 'breakdown' => $this->impact_breakdown_for( $latest['id'] ), 'at' => $latest['completedAt'], ] ); } private function normalize_impact( string $impact ): string { $impact = strtolower( $impact ); return in_array( $impact, [ 'critical', 'serious', 'moderate', 'minor' ], true ) ? $impact : 'minor'; } /** * @param array $row * @return array */ private function map_scan_row( array $row ): array { return [ 'id' => (int) $row['id'], 'uuid' => $row['scan_uuid'] ?? null, 'url' => $row['url'], 'status' => $row['status'], 'score' => isset( $row['score'] ) ? (int) $row['score'] : null, 'wcagLevel' => $row['wcag_level'] ?? 'AA', 'issuesCount' => (int) ( $row['issues_count'] ?? 0 ), 'createdAt' => $row['created_at'], 'completedAt' => $row['completed_at'] ?? null, ]; } }