admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'acceguru_issue_sort_rebuild' ), // A rebuild that keeps moving says nothing at all; only a stall // or a failed request has a message to show. 'stalledText' => __( 'A11y sorting recalculation is not moving. Keep this admin page open or reload it to retry.', 'acceguru' ), 'errorText' => __( 'A11y sorting recalculation paused. Reload this page to retry.', 'acceguru' ), 'reloadDelay' => 350, ) ); } /** * Hooks the column callbacks onto every public post type list screen. */ public function register_columns(): void { $types = get_post_types( array( 'public' => true ), 'names' ); foreach ( $types as $type ) { add_filter( "manage_{$type}_posts_columns", array( $this, 'add_column' ) ); add_action( "manage_{$type}_posts_custom_column", array( $this, 'render_column' ), 10, 2 ); add_filter( "manage_edit-{$type}_sortable_columns", array( $this, 'make_sortable' ) ); } } /** * Inserts the "A11y" column right before the Date column when present. * * @param array $columns Existing columns. * @return array */ public function add_column( array $columns ): array { $new = array(); foreach ( $columns as $key => $label ) { if ( 'date' === $key ) { $new['acceguru_issues'] = $this->column_label(); } $new[ $key ] = $label; } if ( ! isset( $new['acceguru_issues'] ) ) { $new['acceguru_issues'] = $this->column_label(); } return $new; } /** * Returns the column heading. * * A rebuild after an update is routine housekeeping, so it stays out of the * heading: the column keeps its plain name and only loses the sorting link * until the values are ready. */ private function column_label(): string { return esc_html__( 'A11y', 'acceguru' ); } /** * Renders the column cell for a single post. * * @param string $column Column key. * @param int $post_id Post ID. */ public function render_column( string $column, int $post_id ): void { if ( 'acceguru_issues' !== $column ) { return; } $summary = get_post_meta( $post_id, self::META_KEY, true ); if ( ! is_array( $summary ) ) { echo '-'; return; } if ( ! Acceguru_Scanner::summary_is_current( $summary ) ) { echo '↻'; return; } $counts = self::visible_issue_counts( $post_id, $summary ); $total = $counts['total']; $by_impact = $counts['by_impact']; $worst = $this->worst_impact( $by_impact ); if ( 0 === $total ) { printf( '%s 0', esc_attr__( 'No automated violations found in last scan', 'acceguru' ), '✓' // ✓ ); return; } $tooltip_parts = array(); foreach ( array( 'critical', 'serious', 'moderate', 'minor' ) as $level ) { $count = isset( $by_impact[ $level ] ) ? (int) $by_impact[ $level ] : 0; if ( $count > 0 ) { $tooltip_parts[] = Acceguru_Admin::impact_label( $level ) . ': ' . $count; } } $view_url = (string) get_permalink( $post_id ); $href = '' !== $view_url ? add_query_arg( 'acceguru', 'open', $view_url ) : ''; printf( '%4$s', esc_attr( $worst ), '' !== $href ? ' href="' . esc_url( $href ) . '" target="_blank" rel="noopener noreferrer"' : '', ' title="' . esc_attr( implode( ' · ', $tooltip_parts ) ) . '"', esc_html( (string) (int) $total ) ); } /** * Counts issues that remain visible after Free and add-on filters. * * Falls back to the saved summary when an older scan has no detailed issue * list, so a page with legacy results is never shown as clean by mistake. * * @param int $post_id Post being rendered. * @param array $summary Saved scan summary. * @return array{total:int,by_impact:array} */ public static function visible_issue_counts( int $post_id, array $summary ): array { $issues = get_post_meta( $post_id, '_acceguru_scan_results', true ); if ( ! is_array( $issues ) || empty( $issues ) ) { return array( 'total' => isset( $summary['total_issues'] ) ? (int) $summary['total_issues'] : 0, 'by_impact' => isset( $summary['by_impact'] ) && is_array( $summary['by_impact'] ) ? $summary['by_impact'] : array(), ); } $issues = Acceguru_Scanner::filter_issues( $issues, $post_id ); $issues = array_values( array_filter( $issues, static fn( array $issue ): bool => '' !== (string) ( $issue['rule_id'] ?? '' ) ) ); $by_impact = array( 'critical' => 0, 'serious' => 0, 'moderate' => 0, 'minor' => 0, ); foreach ( $issues as $issue ) { $impact = (string) ( $issue['impact'] ?? 'minor' ); if ( isset( $by_impact[ $impact ] ) ) { ++$by_impact[ $impact ]; } } return array( 'total' => count( $issues ), 'by_impact' => $by_impact, ); } /** * Marks the column sortable. * * @param array $columns Sortable columns. * @return array */ public function make_sortable( array $columns ): array { if ( self::rebuild_state() ) { unset( $columns['acceguru_issues'] ); return $columns; } $columns['acceguru_issues'] = 'acceguru_issues'; return $columns; } /** * Orders the list table by the numeric issue count when the A11y column * header is clicked. * * @param WP_Query $query Query object. */ public function handle_sort( WP_Query $query ): void { if ( ! is_admin() || ! $query->is_main_query() ) { return; } if ( 'acceguru_issues' !== $query->get( 'orderby' ) ) { return; } if ( self::rebuild_state() ) { $query->set( 'orderby', 'date' ); $query->set( 'order', 'DESC' ); return; } $order = 'DESC' === strtoupper( (string) $query->get( 'order' ) ) ? 'DESC' : 'ASC'; $query->set( 'acceguru_issue_sort_order', $order ); // Give WP_Query a valid native order. apply_sort_clauses() replaces it // after WordPress has assembled the rest of the list-table query. $query->set( 'orderby', 'ID' ); $query->set( 'order', 'ASC' ); } /** * Applies the three sorting groups: current numbers, stale scans, unscanned. * * @param array $clauses SQL clauses built by WordPress. * @param WP_Query $query Current query. * @return array */ public function apply_sort_clauses( array $clauses, WP_Query $query ): array { $order = (string) $query->get( 'acceguru_issue_sort_order' ); if ( ! in_array( $order, array( 'ASC', 'DESC' ), true ) ) { return $clauses; } global $wpdb; $clauses['join'] .= $wpdb->prepare( " LEFT JOIN {$wpdb->postmeta} AS ag_sort_count ON ({$wpdb->posts}.ID = ag_sort_count.post_id AND ag_sort_count.meta_key = %s)", self::VISIBLE_COUNT_KEY ); $clauses['join'] .= $wpdb->prepare( " LEFT JOIN {$wpdb->postmeta} AS ag_sort_profile ON ({$wpdb->posts}.ID = ag_sort_profile.post_id AND ag_sort_profile.meta_key = %s)", self::VISIBLE_PROFILE_KEY ); $clauses['join'] .= $wpdb->prepare( " LEFT JOIN {$wpdb->postmeta} AS ag_sort_context ON ({$wpdb->posts}.ID = ag_sort_context.post_id AND ag_sort_context.meta_key = %s)", self::VISIBLE_CONTEXT_KEY ); $clauses['join'] .= $wpdb->prepare( " LEFT JOIN {$wpdb->postmeta} AS ag_sort_contract ON ({$wpdb->posts}.ID = ag_sort_contract.post_id AND ag_sort_contract.meta_key = %s)", self::VISIBLE_CONTRACT_KEY ); $clauses['join'] .= $wpdb->prepare( " LEFT JOIN {$wpdb->postmeta} AS ag_sort_record ON ({$wpdb->posts}.ID = ag_sort_record.post_id AND ag_sort_record.meta_key = %s)", self::VISIBLE_RECORD_KEY ); $numeric = $wpdb->prepare( '(ag_sort_record.meta_value = %s AND ag_sort_profile.meta_value = %s AND ag_sort_context.meta_value = %s AND ag_sort_contract.meta_value = %s AND ag_sort_count.meta_value IS NOT NULL)', 'current', Acceguru_Scanner::scan_profile_compatibility_hash(), self::sort_context_hash(), self::scan_contract_key() ); $group_expression = "CASE WHEN {$numeric} THEN 0 WHEN ag_sort_record.meta_value IN ('current', 'stale') THEN 1 ELSE 2 END"; $value_expression = "CASE WHEN {$numeric} THEN CAST(ag_sort_count.meta_value AS SIGNED) END"; if ( '' === (string) $query->get( 'fields' ) ) { // Keep the expressions in SELECT so DISTINCT added by multilingual or // search plugins can still order by them on strict MySQL servers. $clauses['fields'] .= ", {$group_expression} AS acceguru_sort_group, {$value_expression} AS acceguru_sort_value"; $clauses['orderby'] = "acceguru_sort_group ASC, acceguru_sort_value {$order}, {$wpdb->posts}.ID ASC"; } else { $clauses['orderby'] = "{$group_expression} ASC, {$value_expression} {$order}, {$wpdb->posts}.ID ASC"; } return $clauses; } /** * Recalculates the stored sorting value for one post. * * Called after a scan save, during an Ignore/Restore rebuild, and by add-ons * after a per-post filter changes. */ public static function refresh_sort_value( int $post_id ): void { $summary = get_post_meta( $post_id, self::META_KEY, true ); if ( ! is_array( $summary ) ) { delete_post_meta( $post_id, self::VISIBLE_COUNT_KEY ); delete_post_meta( $post_id, self::VISIBLE_PROFILE_KEY ); delete_post_meta( $post_id, self::VISIBLE_CONTEXT_KEY ); delete_post_meta( $post_id, self::VISIBLE_CONTRACT_KEY ); delete_post_meta( $post_id, self::VISIBLE_RECORD_KEY ); return; } $counts = self::visible_issue_counts( $post_id, $summary ); $saved_profile = isset( $summary['scan_profile'] ) && is_array( $summary['scan_profile'] ) ? $summary['scan_profile'] : array(); update_post_meta( $post_id, self::VISIBLE_COUNT_KEY, (int) $counts['total'] ); update_post_meta( $post_id, self::VISIBLE_PROFILE_KEY, Acceguru_Scanner::scan_profile_compatibility_hash( $saved_profile ) ); update_post_meta( $post_id, self::VISIBLE_CONTEXT_KEY, self::sort_context_hash() ); update_post_meta( $post_id, self::VISIBLE_CONTRACT_KEY, self::scan_contract_key( $summary ) ); update_post_meta( $post_id, self::VISIBLE_RECORD_KEY, Acceguru_Scanner::summary_is_current( $summary ) ? 'current' : 'stale' ); } /** * Returns the schema and ruleset identity for a saved or current scan. * * @param array|null $summary Saved scan summary, or null for today's contract. */ private static function scan_contract_key( ?array $summary = null ): string { $schema = null === $summary ? Acceguru_Scanner::SCAN_SCHEMA_VERSION : (int) ( $summary['schema_version'] ?? 0 ); $ruleset = null === $summary ? Acceguru_Scanner::RULESET_VERSION : (string) ( $summary['ruleset_version'] ?? '' ); return $schema . ':' . $ruleset; } /** * Returns a stable hash for the add-ons that alter visible issue counts. * * Free owns only the contract. Add-ons append stable scalar/array values; * changing one starts a resumable rebuild without Free naming the add-on. */ public static function sort_context_hash(): string { $ignored = Acceguru_Scanner::get_ignored_rules(); sort( $ignored, SORT_STRING ); $context = array( 'schema' => self::SORT_SCHEMA, 'ignored_rules' => array_values( $ignored ), ); $context = apply_filters( 'acceguru_issue_sort_context', $context ); if ( ! is_array( $context ) ) { $context = array( 'schema' => self::SORT_SCHEMA, 'ignored_rules' => array_values( $ignored ), ); } return hash( 'sha256', (string) wp_json_encode( self::canonicalize_context( $context ) ) ); } /** * Starts or resumes the sorting-cache migration on relevant admin screens. */ public function prepare_rebuild(): void { if ( ! current_user_can( 'edit_posts' ) ) { return; } self::ensure_rebuild_state(); $state = self::rebuild_state(); if ( ! $state ) { return; } global $pagenow; $requested_sort = isset( $_GET['orderby'] ) ? sanitize_key( (string) wp_unslash( $_GET['orderby'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only list sorting. if ( 'edit.php' === $pagenow && 'acceguru_issues' === $requested_sort && ! wp_doing_ajax() ) { $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( (string) wp_unslash( $_SERVER['REQUEST_URI'] ) ) : admin_url( 'edit.php' ); $target = remove_query_arg( array( 'orderby', 'order' ), $request_uri ); $target = add_query_arg( 'acceguru_sort_reset', '1', $target ); wp_safe_redirect( $target ); exit; } if ( current_user_can( 'manage_options' ) && self::is_rebuild_screen() && ! wp_doing_ajax() ) { self::process_rebuild_batch(); } } /** * Shows why sorting is temporarily unavailable and how far the rebuild got. */ public function render_rebuild_notice(): void { if ( ! self::is_rebuild_screen() ) { return; } $state = self::rebuild_state(); $was_reset = isset( $_GET['acceguru_sort_reset'] ) && '1' === sanitize_text_field( (string) wp_unslash( $_GET['acceguru_sort_reset'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only notice flag. if ( ! $state ) { if ( $was_reset ) { ?>

120; if ( $was_reset ) : ?>

>

__( 'You are not allowed to rebuild A11y sorting data.', 'acceguru' ) ), 403 ); } $result = self::process_rebuild_batch(); $state = self::rebuild_state(); wp_send_json_success( array( 'done' => ! $state, 'busy' => ! empty( $result['busy'] ), 'remaining' => $state ? (int) $state['remaining'] : 0, 'stalled' => $state ? ( time() - (int) $state['last_progress'] ) > 120 : false, ) ); } /** * Returns the active rebuild state, or null when sorting is ready. * * @return array|null */ private static function rebuild_state(): ?array { $state = get_option( self::REBUILD_OPTION, null ); if ( ! is_array( $state ) || empty( $state['context'] ) ) { return null; } return array( 'context' => (string) $state['context'], 'last_id' => (int) ( $state['last_id'] ?? 0 ), 'total' => (int) ( $state['total'] ?? 0 ), 'processed' => (int) ( $state['processed'] ?? 0 ), 'remaining' => (int) ( $state['remaining'] ?? 0 ), 'started_at' => (int) ( $state['started_at'] ?? time() ), 'last_progress' => (int) ( $state['last_progress'] ?? time() ), ); } /** * Creates a new resumable rebuild when the schema or add-on context changed. */ private static function ensure_rebuild_state(): void { $current_context = self::sort_context_hash(); $completed = get_option( self::CONTEXT_OPTION, '' ); $completed = is_scalar( $completed ) ? (string) $completed : ''; $schema = (int) get_option( self::SCHEMA_OPTION, 0 ); if ( self::SORT_SCHEMA === $schema && hash_equals( $current_context, $completed ) ) { delete_option( self::REBUILD_OPTION ); return; } $state = self::rebuild_state(); if ( $state && hash_equals( $current_context, (string) $state['context'] ) ) { return; } global $wpdb; $total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- One count starts a bounded migration. $wpdb->prepare( "SELECT COUNT(DISTINCT post_id) FROM {$wpdb->postmeta} WHERE meta_key = %s", self::META_KEY ) ); $now = time(); update_option( self::REBUILD_OPTION, array( 'context' => $current_context, 'last_id' => 0, 'total' => $total, 'processed' => 0, 'remaining' => $total, 'started_at' => $now, 'last_progress' => $now, ), false ); } /** * Rebuilds one bounded batch, protected by an atomic cross-tab lock. * * @return array{busy:bool} */ private static function process_rebuild_batch(): array { self::ensure_rebuild_state(); $state = self::rebuild_state(); if ( ! $state ) { return array( 'busy' => false ); } $existing_lock = get_option( self::LOCK_OPTION, null ); if ( is_array( $existing_lock ) && ( time() - (int) ( $existing_lock['time'] ?? 0 ) ) > 120 ) { delete_option( self::LOCK_OPTION ); } $token = wp_generate_uuid4(); if ( ! add_option( self::LOCK_OPTION, array( 'token' => $token, 'time' => time() ), '', false ) ) { return array( 'busy' => true ); } try { $state = self::rebuild_state(); if ( ! $state || ! hash_equals( self::sort_context_hash(), (string) $state['context'] ) ) { self::ensure_rebuild_state(); return array( 'busy' => false ); } global $wpdb; $ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Resumable primary-key batch. $wpdb->prepare( "SELECT DISTINCT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s AND post_id > %d ORDER BY post_id ASC LIMIT %d", self::META_KEY, (int) $state['last_id'], self::REBUILD_BATCH_SIZE ) ); $ids = array_values( array_filter( array_map( 'absint', is_array( $ids ) ? $ids : array() ) ) ); foreach ( $ids as $post_id ) { self::refresh_sort_value( $post_id ); } if ( empty( $ids ) || count( $ids ) < self::REBUILD_BATCH_SIZE ) { update_option( self::CONTEXT_OPTION, (string) $state['context'], false ); update_option( self::SCHEMA_OPTION, self::SORT_SCHEMA, false ); delete_option( self::REBUILD_OPTION ); return array( 'busy' => false ); } $state['last_id'] = (int) max( $ids ); $state['processed'] = min( (int) $state['total'], (int) $state['processed'] + count( $ids ) ); $state['remaining'] = max( 0, (int) $state['total'] - (int) $state['processed'] ); $state['last_progress'] = time(); update_option( self::REBUILD_OPTION, $state, false ); return array( 'busy' => false ); } finally { $lock = get_option( self::LOCK_OPTION, null ); if ( is_array( $lock ) && hash_equals( $token, (string) ( $lock['token'] ?? '' ) ) ) { delete_option( self::LOCK_OPTION ); } } } /** * Whether this request can visibly advance or report the rebuild. */ private static function is_rebuild_screen(): bool { global $pagenow; if ( 'edit.php' === $pagenow ) { return true; } $page = isset( $_GET['page'] ) ? sanitize_key( (string) wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only screen detection. return 'acceguru' === $page || str_starts_with( $page, 'acceguru-' ); } /** * Canonicalizes add-on context before hashing it. * * @param mixed $value Context value. * @return mixed */ private static function canonicalize_context( mixed $value ): mixed { if ( ! is_array( $value ) ) { return is_scalar( $value ) || null === $value ? $value : null; } ksort( $value ); foreach ( $value as $key => $item ) { $value[ $key ] = self::canonicalize_context( $item ); } return $value; } /** * Returns the worst impact level present in the per-impact breakdown. * * @param array $by_impact Counts per impact level. */ private function worst_impact( array $by_impact ): string { foreach ( array( 'critical', 'serious', 'moderate', 'minor' ) as $level ) { if ( ! empty( $by_impact[ $level ] ) ) { return $level; } } return 'minor'; } }