This commit is contained in:
Javier Casares 2026-06-03 06:29:56 +00:00
commit 98b1cf0f3b
17 changed files with 2968 additions and 1313 deletions

View file

@ -9,6 +9,7 @@ namespace MediaRightsAudit\Admin;
use MediaRightsAudit\Admin\AttachmentDetailPage;
use MediaRightsAudit\External\ExternalScanner;
use MediaRightsAudit\External\HostnameFilter;
use MediaRightsAudit\External\ResultsConsolidator;
use MediaRightsAudit\Internal\AttachmentIndexer;
use MediaRightsAudit\Internal\UsageScanner;
@ -65,9 +66,9 @@ class AuditPage {
$action = $this->get_current_bulk_action();
if ( 'export_csv' === $action ) {
$this->handle_export_csv();
} elseif ( 'export_external_csv' === $action ) {
$this->handle_export_external_csv();
$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 ) {
@ -346,22 +347,51 @@ class AuditPage {
$html .= '</div>';
if ( ! empty( $domains ) ) {
$html .= '<table class="widefat mra-domains-table">';
$html .= '<thead><tr>';
$html .= '<th>' . esc_html__( 'Domain', 'robotstxt-mediaaudit' ) . '</th>';
$html .= '<th>' . esc_html__( 'Occurrences', 'robotstxt-mediaaudit' ) . '</th>';
$html .= '</tr></thead><tbody>';
$alert_domains = array();
$other_domains = array();
foreach ( $domains as $domain => $count ) {
if ( ! is_string( $domain ) ) {
continue;
}
$html .= sprintf(
'<tr><td>%s</td><td>%s</td></tr>',
esc_html( $domain ),
esc_html( (string) ( is_numeric( $count ) ? (int) $count : 0 ) )
);
$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 .= '<table class="widefat mra-domains-table">';
$html .= '<thead><tr>';
$html .= '<th>' . esc_html__( 'Domain', 'robotstxt-mediaaudit' ) . '</th>';
$html .= '<th>' . esc_html__( 'Occurrences', 'robotstxt-mediaaudit' ) . '</th>';
$html .= '<th>' . esc_html__( 'Status', 'robotstxt-mediaaudit' ) . '</th>';
$html .= '</tr></thead><tbody>';
foreach ( $alert_domains as $domain => $count ) {
$html .= sprintf(
'<tr><td>%s</td><td>%s</td><td><span class="mra-badge mra-badge-alert">%s</span></td></tr>',
esc_html( $domain ),
esc_html( (string) $count ),
esc_html__( 'Alert', 'robotstxt-mediaaudit' )
);
}
foreach ( $other_domains as $domain => $count ) {
$html .= sprintf(
'<tr><td>%s</td><td>%s</td><td></td></tr>',
esc_html( $domain ),
esc_html( (string) $count )
);
}
$html .= '</tbody></table>';
}
$html .= '</tbody></table>';
}
}
@ -452,6 +482,14 @@ class AuditPage {
);
}
if ( $stats['total_alert'] > 0 ) {
$this->stat_box(
$stats['total_alert'],
__( 'Alert', 'robotstxt-mediaaudit' ),
''
);
}
echo '</div>';
}
@ -475,21 +513,26 @@ class AuditPage {
}
/**
* Outputs CSV for selected attachment IDs and terminates the request.
* 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(): void {
private function handle_export_csv( bool $alerts_only ): 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';
$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 . '"' );
@ -511,9 +554,17 @@ class AuditPage {
__( '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 (post titles)', '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' ),
)
);
@ -526,136 +577,188 @@ class AuditPage {
}
/**
* Outputs an external-results CSV for selected attachment IDs and terminates.
* Builds unified CSV rows (one per attachment) with internal and external data.
*
* One row per attachment × provider pair. Attachments with no results still
* appear with empty provider columns.
* 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).
*
* @return void
*/
private function handle_export_external_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_external_csv_rows( $ids );
$filename = 'media-audit-external-' . 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' ) );
}
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' ),
__( 'External Status', 'robotstxt-mediaaudit' ),
__( 'Last Scanned', 'robotstxt-mediaaudit' ),
__( 'Provider', 'robotstxt-mediaaudit' ),
__( 'Match Count', 'robotstxt-mediaaudit' ),
__( 'Top Domains', 'robotstxt-mediaaudit' ),
)
);
foreach ( $rows as $row ) {
fputcsv( $out, $row );
}
fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
exit;
}
/**
* Builds external-results CSV rows for the given attachment IDs.
* 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.
*
* Each row represents one attachment × provider combination. Attachments with
* no provider results are included with empty provider columns. If $ids is
* empty, all indexed attachments are exported.
*
* @param array<int, int> $ids Attachment IDs to export (empty = all).
* @param array<int, int> $ids Attachment IDs to export (empty = all).
* @param bool $alerts_only Include only attachments with alert domains.
*
* @return array<int, array<int, string>>
*/
private function build_external_csv_rows( array $ids ): 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 ) ) {
$rows = $wpdb->get_results(
"SELECT i.attachment_id, i.file_name, i.file_url, i.external_status,
i.external_scanned_at, er.provider, er.match_count, er.top_domains
FROM {$wpdb->prefix}mra_media_index i
LEFT JOIN {$wpdb->prefix}mra_external_results er ON er.attachment_id = i.attachment_id
ORDER BY i.attachment_id ASC, er.provider ASC",
$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' ) );
$rows = $wpdb->get_results(
$index_rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT i.attachment_id, i.file_name, i.file_url, i.external_status,
i.external_scanned_at, er.provider, er.match_count, er.top_domains
FROM {$wpdb->prefix}mra_media_index i
LEFT JOIN {$wpdb->prefix}mra_external_results er ON er.attachment_id = i.attachment_id
WHERE i.attachment_id IN ({$placeholders})
ORDER BY i.attachment_id ASC, er.provider ASC",
"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 ( ! $rows ) {
if ( ! is_array( $index_rows ) || empty( $index_rows ) ) {
return array();
}
$output = array();
$page_ids = array_map(
static function ( $v ): int {
return (int) $v;
},
array_column( $index_rows, 'attachment_id' )
);
foreach ( $rows as $r ) {
$aid_raw = $r['attachment_id'] ?? '';
$scanned_raw = $r['external_scanned_at'] ?? '';
$domains_raw = $r['top_domains'] ?? '';
$mc_raw = $r['match_count'] ?? '';
// 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;
}
$domains_decoded = is_string( $domains_raw ) && '' !== $domains_raw
? json_decode( $domains_raw, true )
: array();
$domain_parts = array();
if ( is_array( $domains_decoded ) ) {
foreach ( $domains_decoded as $d ) {
if ( is_string( $d ) ) {
$domain_parts[] = $d;
// 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<int, array<string, array{match_count: int, domains: array<string, int>}>> $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 );
}
}
}
$domains_str = implode( '; ', $domain_parts );
}
$output[] = array(
is_numeric( $aid_raw ) ? (string) (int) $aid_raw : '',
$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 );
if ( $alerts_only && ! $has_alert ) {
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'] : '',
is_string( $scanned_raw ) ? $scanned_raw : '',
is_string( $r['provider'] ?? null ) ? (string) $r['provider'] : '',
is_numeric( $mc_raw ) ? (string) (int) $mc_raw : '',
$domains_str,
$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 );
$output[] = $row_data;
}
return $output;
@ -745,87 +848,6 @@ class AuditPage {
return $ids;
}
/**
* Builds CSV data rows for the given attachment IDs.
*
* If $ids is empty, exports all indexed attachments.
*
* @param array<int, int> $ids Attachment IDs to export (empty = all).
*
* @return array<int, array<int, string>>
*/
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.
*
@ -844,7 +866,7 @@ class AuditPage {
/**
* 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}
* @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;
@ -856,13 +878,77 @@ class AuditPage {
$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 {
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 ) ) {
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;
}
}
}
$alert_count = 0;
foreach ( $merged as $domains ) {
if ( HostnameFilter::has_alert_domains( $domains ) ) {
++$alert_count;
}
}
return $alert_count;
}
}