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( false );
} elseif ( 'export_csv_alerts' === $action ) {
$this->handle_export_csv( true );
} 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_others_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 '
';
// Modal overlay (hidden by default, opened via JS).
echo '
';
echo '
';
echo ' ';
echo '
';
echo '
';
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_others_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 .= '| ' . esc_html__( 'Post', 'robotstxt-mediaaudit' ) . ' | ';
$usage_html .= '' . esc_html__( 'Type', 'robotstxt-mediaaudit' ) . ' | ';
$usage_html .= '' . esc_html__( 'Context', 'robotstxt-mediaaudit' ) . ' | ';
$usage_html .= '' . esc_html__( 'Status', 'robotstxt-mediaaudit' ) . ' | ';
$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 .= '| ' . $post_cell . ' | ';
$usage_html .= '' . esc_html( $post_type ) . ' | ';
$usage_html .= '' . $ctx_label . ' | ';
$usage_html .= '' . esc_html( $status ) . ' | ';
$usage_html .= '
';
}
$usage_html .= '
';
}
// 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, raw_response, 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',
'picdefense' => 'PicDefense',
);
$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();
$rr_val = $row['raw_response'] ?? null;
$raw = null;
if ( is_string( $rr_val ) && '' !== $rr_val ) {
$rr_decoded = json_decode( $rr_val, true );
$raw = is_array( $rr_decoded ) ? $rr_decoded : null;
}
$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 ( 'picdefense' === $slug && null !== $raw ) {
$pd_data_raw = $raw['data'] ?? null;
$pd_arr = is_array( $pd_data_raw ) ? $pd_data_raw : array();
$pd_data = isset( $pd_arr[0] ) && is_array( $pd_arr[0] ) ? $pd_arr[0] : null;
if ( is_array( $pd_data ) ) {
$pr_val = $pd_data['picrisk'] ?? null;
$picrisk = is_string( $pr_val ) ? strtolower( $pr_val ) : '';
if ( '' !== $picrisk ) {
$html .= ' ' . esc_html( ucfirst( $picrisk ) ) . '';
}
}
}
if ( '' !== $scanned_at ) {
$html .= ' ' . esc_html( $scanned_at ) . '';
}
$html .= '
';
if ( ! empty( $domains ) ) {
$alert_domains = array();
$other_domains = array();
foreach ( $domains as $domain => $count ) {
if ( ! is_string( $domain ) ) {
continue;
}
$classification = HostnameFilter::classify( $domain );
if ( 'alert' === $classification ) {
$alert_domains[ $domain ] = is_numeric( $count ) ? (int) $count : 0;
} elseif ( 'other' === $classification ) {
$other_domains[ $domain ] = is_numeric( $count ) ? (int) $count : 0;
}
// Ignored domains are skipped entirely.
}
$visible_domains = array_merge( $alert_domains, $other_domains );
if ( ! empty( $visible_domains ) ) {
$html .= '';
$html .= '';
$html .= '| ' . esc_html__( 'Domain', 'robotstxt-mediaaudit' ) . ' | ';
$html .= '' . esc_html__( 'Occurrences', 'robotstxt-mediaaudit' ) . ' | ';
$html .= '' . esc_html__( 'Status', 'robotstxt-mediaaudit' ) . ' | ';
$html .= '
';
foreach ( $alert_domains as $domain => $count ) {
$html .= sprintf(
'| %s | %s | %s |
',
esc_html( $domain ),
esc_html( (string) $count ),
esc_html__( 'Alert', 'robotstxt-mediaaudit' )
);
}
foreach ( $other_domains as $domain => $count ) {
$html .= sprintf(
'| %s | %s | |
',
esc_html( $domain ),
esc_html( (string) $count )
);
}
$html .= '
';
}
}
}
// 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 .= '
';
foreach ( $consensus as $domain => $count ) {
$html .= '- ' . esc_html( $domain ) . ' (' . esc_html(
sprintf(
/* translators: %d: number of providers that agree */
_n( '%d provider', '%d providers', $count, 'robotstxt-mediaaudit' ),
$count
)
) . ')
';
}
$html .= '
';
}
}
$html .= ''
. esc_html__( 'View full report →', 'robotstxt-mediaaudit' )
. '
';
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' ),
''
);
}
if ( $stats['total_alert'] > 0 ) {
$this->stat_box(
$stats['total_alert'],
__( 'Alert', '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 the unified CSV export and terminates the request.
*
* One row per attachment. When $alerts_only is true, only attachments that
* have at least one alert domain match are included.
*
* @param bool $alerts_only When true, export only attachments with alert domains.
*
* @return void
*/
private function handle_export_csv( bool $alerts_only ): void {
check_admin_referer( 'bulk-mra-attachments' );
if ( ! current_user_can( 'edit_others_posts' ) ) {
wp_die( esc_html__( 'Insufficient permissions.', 'robotstxt-mediaaudit' ) );
}
$ids = $this->collect_attachment_ids();
$rows = $this->build_unified_csv_rows( $ids, $alerts_only );
$suffix = $alerts_only ? '-alerts' : '';
$filename = 'media-audit' . $suffix . '-' . 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' ),
__( 'Internal Scan Date', 'robotstxt-mediaaudit' ),
__( 'External Status', 'robotstxt-mediaaudit' ),
__( 'Has Alert', 'robotstxt-mediaaudit' ),
__( 'Usage Count', 'robotstxt-mediaaudit' ),
__( 'Used In', 'robotstxt-mediaaudit' ),
__( 'Google Vision — Matches', 'robotstxt-mediaaudit' ),
__( 'TinEye — Matches', 'robotstxt-mediaaudit' ),
__( 'PicDefense — Matches', 'robotstxt-mediaaudit' ),
__( 'Alert Domains', 'robotstxt-mediaaudit' ),
__( 'Other Domains', 'robotstxt-mediaaudit' ),
__( 'Ignored Domains', 'robotstxt-mediaaudit' ),
__( 'Dismissed', 'robotstxt-mediaaudit' ),
__( 'Dismissed Notes', 'robotstxt-mediaaudit' ),
)
);
foreach ( $rows as $row ) {
fputcsv( $out, $row );
}
fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
exit;
}
/**
* Builds unified CSV rows (one per attachment) with internal and external data.
*
* Columns: ID, filename, URL, MIME type, file size, internal scan date,
* external status, has alert, usage count, used-in titles, per-provider
* match counts, and classified domain lists (alert / other / ignored).
*
* When $ids is empty all indexed attachments are exported. When $alerts_only
* is true, only attachments with at least one alert domain match are included.
*
* @param array $ids Attachment IDs to export (empty = all).
* @param bool $alerts_only Include only attachments with alert domains.
*
* @return array>
*/
private function build_unified_csv_rows( array $ids, bool $alerts_only ): 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
);
}
if ( ! is_array( $index_rows ) || empty( $index_rows ) ) {
return array();
}
$page_ids = array_map(
static function ( $v ): int {
return (int) $v;
},
array_column( $index_rows, 'attachment_id' )
);
// Fetch usages.
$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;
}
// Fetch external results (all providers) for these attachments.
$ext_placeholders = implode( ',', array_fill( 0, count( $page_ids ), '%d' ) );
$ext_rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT attachment_id, provider, match_count, top_domains
FROM {$wpdb->prefix}mra_external_results
WHERE attachment_id IN ({$ext_placeholders})",
...$page_ids
),
ARRAY_A
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
/**
* Per-attachment, per-provider data: match_count + merged domain→count.
*
* @var array}>> $ext_by_id
*/
$ext_by_id = array();
if ( is_array( $ext_rows ) ) {
foreach ( $ext_rows as $er ) {
if ( ! is_array( $er ) ) {
continue;
}
$aid_val = $er['attachment_id'] ?? null;
$eaid = is_numeric( $aid_val ) ? (int) $aid_val : 0;
$prov_val = $er['provider'] ?? null;
$provider = is_string( $prov_val ) ? $prov_val : '';
$mc_val = $er['match_count'] ?? null;
$mc = is_numeric( $mc_val ) ? (int) $mc_val : 0;
$td_val = $er['top_domains'] ?? null;
$td = is_string( $td_val ) && '' !== $td_val ? json_decode( $td_val, true ) : null;
if ( $eaid <= 0 || '' === $provider ) {
continue;
}
if ( ! isset( $ext_by_id[ $eaid ][ $provider ] ) ) {
$ext_by_id[ $eaid ][ $provider ] = array(
'match_count' => 0,
'domains' => array(),
);
}
$ext_by_id[ $eaid ][ $provider ]['match_count'] += $mc;
if ( is_array( $td ) ) {
foreach ( $td as $domain => $count ) {
if ( ! is_string( $domain ) ) {
continue;
}
$existing = $ext_by_id[ $eaid ][ $provider ]['domains'][ $domain ] ?? 0;
$ext_by_id[ $eaid ][ $provider ]['domains'][ $domain ] = $existing + ( is_numeric( $count ) ? (int) $count : 0 );
}
}
}
}
// Fetch dismissed IDs and notes for CSV columns.
$dismissed_ids = AlertsPage::get_dismissed_ids();
// phpcs:disable WordPress.DB.DirectDatabaseQuery
$dismissed_notes_rows = $wpdb->get_results(
"SELECT attachment_id, notes FROM {$wpdb->prefix}mra_dismissed_alerts",
ARRAY_A
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery
$dismissed_notes = array();
if ( is_array( $dismissed_notes_rows ) ) {
foreach ( $dismissed_notes_rows as $dn ) {
if ( is_array( $dn ) ) {
$dn_aid_raw = $dn['attachment_id'] ?? null;
$dn_aid = is_numeric( $dn_aid_raw ) ? (int) $dn_aid_raw : 0;
if ( $dn_aid > 0 ) {
$dismissed_notes[ $dn_aid ] = is_string( $dn['notes'] ?? null ) ? (string) $dn['notes'] : '';
}
}
}
}
$known_providers = array( 'google_vision', 'tineye', 'picdefense' );
$output = array();
foreach ( $index_rows as $r ) {
if ( ! is_array( $r ) ) {
continue;
}
$aid_val = $r['attachment_id'] ?? null;
$aid = is_numeric( $aid_val ) ? (int) $aid_val : 0;
// Aggregate domains across all providers for this attachment.
$all_domains = array();
foreach ( $known_providers as $p ) {
$p_domains = $ext_by_id[ $aid ][ $p ]['domains'] ?? array();
foreach ( $p_domains as $domain => $count ) {
$existing = $all_domains[ $domain ] ?? 0;
$all_domains[ $domain ] = $existing + $count;
}
}
// Classify domains.
$alert_list = array();
$other_list = array();
$ignored_list = array();
foreach ( array_keys( $all_domains ) as $domain ) {
$class = HostnameFilter::classify( $domain );
if ( 'alert' === $class ) {
$alert_list[] = $domain;
} elseif ( 'ignored' === $class ) {
$ignored_list[] = $domain;
} else {
$other_list[] = $domain;
}
}
$has_alert = ! empty( $alert_list );
$is_dismissed = isset( $dismissed_ids[ $aid ] );
// Skip row if alerts_only and either no alert or already dismissed.
if ( $alerts_only && ( ! $has_alert || $is_dismissed ) ) {
continue;
}
$row_data = array(
(string) $aid,
is_string( $r['file_name'] ?? null ) ? (string) $r['file_name'] : '',
is_string( $r['file_url'] ?? null ) ? (string) $r['file_url'] : '',
is_string( $r['mime_type'] ?? null ) ? (string) $r['mime_type'] : '',
is_numeric( $r['file_size'] ?? null ) ? (string) (int) $r['file_size'] : '',
is_string( $r['internal_scanned_at'] ?? null ) ? (string) $r['internal_scanned_at'] : '',
is_string( $r['external_status'] ?? null ) ? (string) $r['external_status'] : '',
$has_alert ? __( 'Yes', 'robotstxt-mediaaudit' ) : __( 'No', 'robotstxt-mediaaudit' ),
(string) count( $usage_titles[ $aid ] ?? array() ),
implode( '; ', $usage_titles[ $aid ] ?? array() ),
);
foreach ( $known_providers as $p ) {
$row_data[] = isset( $ext_by_id[ $aid ][ $p ] ) ? (string) $ext_by_id[ $aid ][ $p ]['match_count'] : '';
}
$row_data[] = implode( '; ', $alert_list );
$row_data[] = implode( '; ', $other_list );
$row_data[] = implode( '; ', $ignored_list );
$row_data[] = $is_dismissed ? __( 'Yes', 'robotstxt-mediaaudit' ) : __( 'No', 'robotstxt-mediaaudit' );
$row_data[] = $dismissed_notes[ $aid ] ?? '';
$output[] = $row_data;
}
return $output;
}
/**
* 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_others_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_others_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;
}
/**
* 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, total_alert: 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
$total_alert = self::get_alert_attachment_count();
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,
'total_alert' => $total_alert,
'pending_index' => AttachmentIndexer::get_pending_count(),
);
}
/**
* Counts the number of unique attachments that have at least one alert domain
* in their external scan results.
*
* @return int
*/
private static function get_alert_attachment_count(): int {
$cached = get_transient( 'mra_alert_count' );
if ( is_int( $cached ) ) {
return $cached;
}
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$rows = $wpdb->get_results(
"SELECT attachment_id, top_domains FROM {$wpdb->prefix}mra_external_results",
ARRAY_A
);
if ( ! is_array( $rows ) ) {
set_transient( 'mra_alert_count', 0, HOUR_IN_SECONDS );
return 0;
}
// Group top_domains by attachment_id and check for alert domains.
$merged = array();
foreach ( $rows as $row ) {
if ( ! is_array( $row ) ) {
continue;
}
$aid_val = $row['attachment_id'] ?? null;
$aid = is_numeric( $aid_val ) ? (int) $aid_val : 0;
if ( $aid <= 0 ) {
continue;
}
$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();
if ( ! isset( $merged[ $aid ] ) ) {
$merged[ $aid ] = array();
}
foreach ( $domains as $domain => $count ) {
if ( ! is_string( $domain ) ) {
continue;
}
$c = is_numeric( $count ) ? (int) $count : 0;
if ( isset( $merged[ $aid ][ $domain ] ) ) {
$merged[ $aid ][ $domain ] += $c;
} else {
$merged[ $aid ][ $domain ] = $c;
}
}
}
$dismissed = AlertsPage::get_dismissed_ids();
$alert_count = 0;
foreach ( $merged as $aid => $domains ) {
if ( isset( $dismissed[ $aid ] ) ) {
continue;
}
if ( HostnameFilter::has_alert_domains( $domains ) ) {
++$alert_count;
}
}
set_transient( 'mra_alert_count', $alert_count, HOUR_IN_SECONDS );
return $alert_count;
}
}