hook_suffix = $suffix; add_action( 'load-' . $suffix, array( $this, 'handle_load' ) ); } /** * Fires before the admin header is sent, allowing headers to be set. * * Handles bulk actions that need to modify headers (CSV export) or redirect * (run_external_scan, purge_external_data) before any HTML output begins. * * @return void */ public function handle_load(): void { $action = $this->get_current_bulk_action(); if ( 'export_csv' === $action ) { $this->handle_export_csv(); } elseif ( 'run_external_scan' === $action ) { $this->handle_run_external_scan(); } elseif ( 'purge_external_data' === $action ) { $this->handle_purge_external_data(); } } /** * Enqueues CSS and JS only on the Media Audit admin page. * * @param string $hook_suffix Current admin page hook suffix. * * @return void */ public function enqueue_assets( string $hook_suffix ): void { if ( '' !== $this->hook_suffix && $hook_suffix !== $this->hook_suffix ) { return; } $base = ROBOTSTXT_MEDIAAUDIT_PLUGIN_URL . 'assets/'; $ver = ROBOTSTXT_MEDIAAUDIT_VERSION; wp_enqueue_style( 'mra-admin', $base . 'css/media-audit-admin.css', array(), $ver ); wp_enqueue_script( 'mra-admin', $base . 'js/media-audit-admin.js', array( 'jquery' ), $ver, true ); wp_localize_script( 'mra-admin', 'mraAdmin', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'mra_usage_details' ), 'i18n' => array( 'loading' => __( 'Loading…', 'robotstxt-mediaaudit' ), 'errorLoading' => __( 'Error loading details.', 'robotstxt-mediaaudit' ), 'noUsages' => __( 'This image is not used in any post.', 'robotstxt-mediaaudit' ), 'detailsTitle' => __( 'Usage Details', 'robotstxt-mediaaudit' ), ), ) ); } /** * Renders the full Media Audit admin page. * * Bulk actions (CSV export, external scan, purge) are intercepted earlier in * handle_load() via the load-{hook_suffix} action, before any HTML is sent. * * @return void */ public function render(): void { if ( ! current_user_can( 'edit_posts' ) ) { wp_die( esc_html__( 'You do not have permission to access this page.', 'robotstxt-mediaaudit' ) ); } $table = new MediaListTable(); $table->prepare_items(); echo '
'; echo '

' . esc_html__( 'Media Audit', 'robotstxt-mediaaudit' ) . '

'; $this->render_dashboard_stats(); echo '
'; echo ''; $table->search_box( __( 'Search', 'robotstxt-mediaaudit' ), 'mra-search' ); $table->display(); echo '
'; // Modal overlay (hidden by default, opened via JS). echo ''; echo '
'; // .wrap } /** * AJAX handler: returns formatted HTML for the usage-detail modal. * * @return void */ public function ajax_usage_details(): void { check_ajax_referer( 'mra_usage_details', 'nonce' ); if ( ! current_user_can( 'edit_posts' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'robotstxt-mediaaudit' ) ) ); } $attachment_id = isset( $_POST['attachment_id'] ) && is_string( $_POST['attachment_id'] ) ? (int) sanitize_text_field( wp_unslash( $_POST['attachment_id'] ) ) : 0; if ( $attachment_id <= 0 ) { wp_send_json_error( array( 'message' => __( 'Invalid attachment ID.', 'robotstxt-mediaaudit' ) ) ); } $usages = MediaListTable::fetch_usages( array( $attachment_id ) ); $title = get_the_title( $attachment_id ); // Build usage section. $context_labels = array( 'featured' => __( 'Featured Image', 'robotstxt-mediaaudit' ), 'content' => __( 'Post Content', 'robotstxt-mediaaudit' ), 'meta' => __( 'Custom Field', 'robotstxt-mediaaudit' ), ); if ( empty( $usages ) ) { $usage_html = '

' . esc_html__( 'This image is not used in any post.', 'robotstxt-mediaaudit' ) . '

'; } else { $usage_html = ''; $usage_html .= ''; $usage_html .= ''; $usage_html .= ''; $usage_html .= ''; $usage_html .= ''; $usage_html .= ''; foreach ( $usages as $u ) { $pid_val = $u['post_id'] ?? null; $post_id = is_numeric( $pid_val ) ? (int) $pid_val : 0; $t_raw = $u['post_title'] ?? ''; $post_title = is_string( $t_raw ) && '' !== $t_raw ? $t_raw : sprintf( '#%d', $post_id ); $pt_val = $u['post_type'] ?? null; $post_type = is_string( $pt_val ) ? $pt_val : ''; $ctx_val = $u['context'] ?? null; $context = is_string( $ctx_val ) ? $ctx_val : ''; $status_val = $u['post_status'] ?? null; $status = is_string( $status_val ) ? $status_val : ''; $mk_val = $u['meta_key'] ?? null; $meta_key = is_string( $mk_val ) ? $mk_val : ''; $ctx_label = isset( $context_labels[ $context ] ) ? $context_labels[ $context ] : esc_html( $context ); if ( '' !== $meta_key ) { $ctx_label .= ' ' . esc_html( $meta_key ) . ''; } $edit_link = get_edit_post_link( $post_id ); $post_cell = $edit_link ? sprintf( '%s', esc_url( $edit_link ), esc_html( $post_title ) ) : esc_html( $post_title ); $usage_html .= ''; $usage_html .= ''; $usage_html .= ''; $usage_html .= ''; $usage_html .= ''; $usage_html .= ''; } $usage_html .= '
' . esc_html__( 'Post', 'robotstxt-mediaaudit' ) . '' . esc_html__( 'Type', 'robotstxt-mediaaudit' ) . '' . esc_html__( 'Context', 'robotstxt-mediaaudit' ) . '' . esc_html__( 'Status', 'robotstxt-mediaaudit' ) . '
' . $post_cell . '' . esc_html( $post_type ) . '' . $ctx_label . '' . esc_html( $status ) . '
'; } // Build external results section. $external_html = $this->build_external_results_html( $attachment_id ); // Combine sections; add headings only when both sections are present. if ( '' !== $external_html ) { $html = '

' . esc_html__( 'Internal Usage', 'robotstxt-mediaaudit' ) . '

'; $html .= $usage_html; $html .= $external_html; } else { $html = $usage_html; } wp_send_json_success( array( 'title' => esc_html( $title ? $title : sprintf( '#%d', $attachment_id ) ), 'html' => $html, ) ); } /** * Builds the HTML for the external scan results section of the detail modal. * * Returns an empty string when no external scan data exists for the attachment. * * @param int $attachment_id WordPress attachment ID. * * @return string */ private function build_external_results_html( int $attachment_id ): string { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared $rows = $wpdb->get_results( $wpdb->prepare( "SELECT provider, match_count, top_domains, created_at FROM {$wpdb->prefix}mra_external_results WHERE attachment_id = %d ORDER BY provider ASC", $attachment_id ), ARRAY_A ); if ( empty( $rows ) ) { return ''; } $provider_names = array( 'google_vision' => 'Google Cloud Vision', 'tineye' => 'TinEye', ); $html = '

' . esc_html__( 'External Scan Results', 'robotstxt-mediaaudit' ) . '

'; foreach ( $rows as $row ) { if ( ! is_array( $row ) ) { continue; } $slug_val = $row['provider'] ?? null; $slug = is_string( $slug_val ) ? $slug_val : ''; $name = isset( $provider_names[ $slug ] ) ? $provider_names[ $slug ] : ucwords( str_replace( '_', ' ', $slug ) ); $mc_val = $row['match_count'] ?? null; $match_count = is_numeric( $mc_val ) ? (int) $mc_val : 0; $at_val = $row['created_at'] ?? null; $scanned_at = is_string( $at_val ) ? $at_val : ''; $td_val = $row['top_domains'] ?? null; $td_raw = is_string( $td_val ) ? json_decode( $td_val, true ) : null; $domains = is_array( $td_raw ) ? $td_raw : array(); $html .= '
'; $html .= '' . esc_html( $name ) . ' — '; if ( $match_count > 0 ) { $html .= esc_html( sprintf( /* translators: %d: number of external matches */ _n( '%d match', '%d matches', $match_count, 'robotstxt-mediaaudit' ), $match_count ) ); } else { $html .= esc_html__( 'No matches', 'robotstxt-mediaaudit' ); } if ( '' !== $scanned_at ) { $html .= ' ' . esc_html( $scanned_at ) . ''; } $html .= '
'; if ( ! empty( $domains ) ) { $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; foreach ( $domains as $domain => $count ) { if ( ! is_string( $domain ) ) { continue; } $html .= sprintf( '', esc_html( $domain ), esc_html( (string) ( is_numeric( $count ) ? (int) $count : 0 ) ) ); } $html .= '
' . esc_html__( 'Domain', 'robotstxt-mediaaudit' ) . '' . esc_html__( 'Occurrences', 'robotstxt-mediaaudit' ) . '
%s%s
'; } } // Consensus section: only when multiple providers have results. if ( count( $rows ) > 1 ) { $consensus = ResultsConsolidator::get_consensus_domains( $attachment_id ); if ( ! empty( $consensus ) ) { $html .= '
'; $html .= '' . esc_html__( 'Consensus Domains', 'robotstxt-mediaaudit' ) . ''; $html .= '
'; } } return $html; } // ------------------------------------------------------------------------- // Private helpers // ------------------------------------------------------------------------- /** * Renders the dashboard stats strip. * * @return void */ private function render_dashboard_stats(): void { $stats = self::get_dashboard_stats(); $scanned_pct = $stats['total_indexed'] > 0 ? round( $stats['total_scanned'] / $stats['total_indexed'] * 100 ) : 0; echo '
'; $this->stat_box( $stats['total_indexed'], __( 'In Index', 'robotstxt-mediaaudit' ), $stats['pending_index'] > 0 ? sprintf( /* translators: %d: count of images not yet indexed */ __( '%d not yet indexed', 'robotstxt-mediaaudit' ), $stats['pending_index'] ) : __( 'All indexed', 'robotstxt-mediaaudit' ) ); $this->stat_box( $stats['total_scanned'], __( 'Scanned', 'robotstxt-mediaaudit' ), sprintf( /* translators: %d: percentage */ __( '%d%% of index', 'robotstxt-mediaaudit' ), $scanned_pct ) ); $this->stat_box( $stats['total_used'], __( 'Used', 'robotstxt-mediaaudit' ), '' ); $this->stat_box( $stats['total_unused'], __( 'Unused', 'robotstxt-mediaaudit' ), '' ); if ( $stats['total_matches'] > 0 ) { $this->stat_box( $stats['total_matches'], __( 'External Matches', 'robotstxt-mediaaudit' ), '' ); } echo '
'; } /** * Outputs a single stat box. * * @param int $value Main number. * @param string $label Short label. * @param string $sub Optional sub-label. * * @return void */ private function stat_box( int $value, string $label, string $sub ): void { echo '
'; echo '' . esc_html( number_format_i18n( $value ) ) . ''; echo '' . esc_html( $label ) . ''; if ( '' !== $sub ) { echo '' . esc_html( $sub ) . ''; } echo '
'; } /** * Outputs CSV for selected attachment IDs and terminates the request. * * @return void */ private function handle_export_csv(): void { check_admin_referer( 'bulk-mra-attachments' ); if ( ! current_user_can( 'edit_posts' ) ) { wp_die( esc_html__( 'Insufficient permissions.', 'robotstxt-mediaaudit' ) ); } $ids = $this->collect_attachment_ids(); $rows = $this->build_csv_rows( $ids ); $filename = 'media-audit-' . gmdate( 'Y-m-d' ) . '.csv'; header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="' . $filename . '"' ); header( 'Pragma: no-cache' ); $out = fopen( 'php://output', 'w' ); if ( false === $out ) { wp_die( esc_html__( 'Could not open output stream.', 'robotstxt-mediaaudit' ) ); } // BOM for Excel UTF-8 compatibility. fwrite( $out, "\xEF\xBB\xBF" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite fputcsv( $out, array( __( 'Attachment ID', 'robotstxt-mediaaudit' ), __( 'Filename', 'robotstxt-mediaaudit' ), __( 'File URL', 'robotstxt-mediaaudit' ), __( 'MIME Type', 'robotstxt-mediaaudit' ), __( 'File Size (bytes)', 'robotstxt-mediaaudit' ), __( 'External Status', 'robotstxt-mediaaudit' ), __( 'Usage Count', 'robotstxt-mediaaudit' ), __( 'Used In (post titles)', 'robotstxt-mediaaudit' ), ) ); foreach ( $rows as $row ) { fputcsv( $out, $row ); } fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose exit; } /** * Queues selected attachments for external scanning and redirects. * * @return void */ private function handle_run_external_scan(): void { check_admin_referer( 'bulk-mra-attachments' ); if ( ! current_user_can( 'edit_posts' ) ) { wp_die( esc_html__( 'Insufficient permissions.', 'robotstxt-mediaaudit' ) ); } $ids = $this->collect_attachment_ids(); if ( ! empty( $ids ) ) { ExternalScanner::queue_attachments( $ids ); } else { ExternalScanner::schedule(); } wp_safe_redirect( add_query_arg( array( 'page' => 'robotstxt-mediaaudit', 'mra_notice' => 'scan_scheduled', ), admin_url( 'admin.php' ) ) ); exit; } /** * Purges external scan data for selected attachments and redirects. * * @return void */ private function handle_purge_external_data(): void { check_admin_referer( 'bulk-mra-attachments' ); if ( ! current_user_can( 'edit_posts' ) ) { wp_die( esc_html__( 'Insufficient permissions.', 'robotstxt-mediaaudit' ) ); } $ids = $this->collect_attachment_ids(); if ( ! empty( $ids ) ) { ExternalScanner::purge( $ids ); } wp_safe_redirect( add_query_arg( array( 'page' => 'robotstxt-mediaaudit', 'mra_notice' => 'purge_done', ), admin_url( 'admin.php' ) ) ); exit; } /** * Collects and sanitises attachment IDs from the current bulk action POST data. * * @return array */ private function collect_attachment_ids(): array { $ids = array(); // phpcs:disable WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended -- nonce verified by all callers. if ( isset( $_REQUEST['attachment_id'] ) && is_array( $_REQUEST['attachment_id'] ) ) { foreach ( $_REQUEST['attachment_id'] as $raw ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $id = is_numeric( $raw ) ? (int) $raw : 0; if ( $id > 0 ) { $ids[] = $id; } } } // phpcs:enable WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended return $ids; } /** * Builds CSV data rows for the given attachment IDs. * * If $ids is empty, exports all indexed attachments. * * @param array $ids Attachment IDs to export (empty = all). * * @return array> */ private function build_csv_rows( array $ids ): array { global $wpdb; // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare if ( empty( $ids ) ) { $index_rows = $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}mra_media_index ORDER BY attachment_id ASC", ARRAY_A ); } else { $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); $index_rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}mra_media_index WHERE attachment_id IN ({$placeholders}) ORDER BY attachment_id ASC", ...$ids ), ARRAY_A ); } // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare if ( ! $index_rows ) { return array(); } $page_ids = array_map( static function ( $v ) { return (int) $v; }, array_column( $index_rows, 'attachment_id' ) ); $usages = MediaListTable::fetch_usages( $page_ids ); $usage_titles = array(); foreach ( $usages as $u ) { $aid_val = $u['attachment_id'] ?? null; $aid = is_numeric( $aid_val ) ? (int) $aid_val : 0; $t_raw = $u['post_title'] ?? ''; $pid_raw = $u['post_id'] ?? null; $title = is_string( $t_raw ) && '' !== $t_raw ? $t_raw : sprintf( '#%d', is_numeric( $pid_raw ) ? (int) $pid_raw : 0 ); $usage_titles[ $aid ][] = $title; } $output = array(); foreach ( $index_rows as $r ) { $aid = (int) ( $r['attachment_id'] ?? 0 ); $titles = isset( $usage_titles[ $aid ] ) ? implode( '; ', $usage_titles[ $aid ] ) : ''; $fn_raw = $r['file_name'] ?? ''; $url_raw = $r['file_url'] ?? ''; $mt_raw = $r['mime_type'] ?? ''; $fs_raw = $r['file_size'] ?? ''; $es_raw = $r['external_status'] ?? ''; $output[] = array( (string) $aid, is_string( $fn_raw ) ? $fn_raw : '', is_string( $url_raw ) ? $url_raw : '', is_string( $mt_raw ) ? $mt_raw : '', is_string( $fs_raw ) ? $fs_raw : '', is_string( $es_raw ) ? $es_raw : '', (string) count( $usage_titles[ $aid ] ?? array() ), $titles, ); } return $output; } /** * Determines the active bulk action from the form submission. * * @return string Action name, or '' if none. */ private function get_current_bulk_action(): string { // phpcs:disable WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended $action = isset( $_REQUEST['action'] ) && is_string( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : ''; if ( '-1' === $action ) { $action = isset( $_REQUEST['action2'] ) && is_string( $_REQUEST['action2'] ) ? sanitize_key( wp_unslash( $_REQUEST['action2'] ) ) : ''; } // phpcs:enable WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended return $action; } /** * Queries and returns dashboard statistics. * * @return array{total_indexed: int, total_scanned: int, total_used: int, total_unused: int, total_matches: int, pending_index: int} */ private static function get_dashboard_stats(): array { global $wpdb; // phpcs:disable WordPress.DB.DirectDatabaseQuery $total_indexed = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_index" ); $total_scanned = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_index WHERE internal_scanned_at IS NOT NULL" ); $total_used = (int) $wpdb->get_var( "SELECT COUNT(DISTINCT attachment_id) FROM {$wpdb->prefix}mra_media_usage" ); $total_matches = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_index WHERE external_status = 'matches'" ); // phpcs:enable WordPress.DB.DirectDatabaseQuery return array( 'total_indexed' => $total_indexed, 'total_scanned' => $total_scanned, 'total_used' => $total_used, 'total_unused' => max( 0, $total_indexed - $total_used ), 'total_matches' => $total_matches, 'pending_index' => AttachmentIndexer::get_pending_count(), ); } }