diff --git a/changelog.txt b/changelog.txt
index 4905d32..2fd2370 100644
--- a/changelog.txt
+++ b/changelog.txt
@@ -1,5 +1,44 @@
== Changelog ==
+= 1.6.0 =
+
+_Release date: 2026-05-05_
+
+**Added**
+
+* Settings page reorganised into four native WordPress tabs: General, API Credentials, Filters, External Scanning. Each tab saves independently via a hidden `_tab` sentinel field, preventing cross-tab data loss.
+* Filters tab: two configurable hostname lists — "Alert Hostnames" (domains that should trigger a copyright-risk alert) and "Ignored Hostnames" (domains to suppress from results). Wildcard notation `*.example.com` matches the apex domain and all subdomains.
+* Auto-normalization on save: scheme, path, query, fragment, port, and `www.` prefix are stripped; duplicates removed; list sorted alphabetically.
+* Default filter lists seeded on first activation (idempotent — only runs when the option key does not yet exist): 60 stock-agency / press-agency alert domains, 56 social-media / CDN ignored domains.
+* `HostnameFilter` utility class (`includes/External/HostnameFilter.php`): static methods `is_alert()`, `is_ignored()`, `classify()` (`alert`|`ignored`|`other`), and `has_alert_domains()`. Domain classification is request-cached.
+* Dashboard stats strip: new "Alert" card showing the count of attachments that have at least one alert-domain match across all external providers.
+* Audit list: "Alert" option added to the External Status filter dropdown; when selected, PHP-side filtering classifies each attachment's `top_domains` and returns only those with at least one alert domain.
+* Audit list: Alert badge in the External Status column for attachments with alert-domain matches; badge label shows the count of non-ignored matching domains.
+* Quick-view modal: ignored domains are now hidden entirely; alert domains are highlighted with an Alert status badge in the domain table.
+* Full Report attachment detail page: external results per provider split into three labelled sections — Alert domains, Other domains, and Ignored domains.
+* Unified CSV export replaces the two previous separate exports:
+ * "Export CSV" — all indexed attachments, one row per attachment, containing: ID, filename, URL, MIME type, file size, internal scan date, external status, has alert (Yes/No), usage count, used-in post titles, Google Vision match count, TinEye match count, PicDefense match count, alert domains, other domains, ignored domains.
+ * "Export CSV (Alerts only)" — same format and columns, filtered to attachments with at least one alert domain match.
+* "Media Audit" top-level admin menu repositioned to appear immediately below the built-in Media menu (WordPress admin menu position 11).
+
+**Removed**
+
+* Previous "Export External Results CSV" bulk action replaced by the unified export above.
+
+**No schema changes:** `ROBOTSTXT_MEDIAAUDIT_DB_VERSION` remains `1.1.0`.
+
+**Compatibility**
+
+* WordPress: 6.8 - 7.0
+* PHP: 8.2 - 8.4
+* WP-CLI: 2.x
+
+**Tests**
+
+* PHP Coding Standards: WPCS 3.x / PHPCS 3.x
+* PHPStan: level 9
+* PHPCompatibility: PHP 8.2 - 8.4
+
= 1.5.0 =
_Release date: 2026-05-05_
diff --git a/includes/Admin/AttachmentDetailPage.php b/includes/Admin/AttachmentDetailPage.php
index c08b7ec..5c0e725 100644
--- a/includes/Admin/AttachmentDetailPage.php
+++ b/includes/Admin/AttachmentDetailPage.php
@@ -7,6 +7,8 @@
namespace MediaRightsAudit\Admin;
+use MediaRightsAudit\External\HostnameFilter;
+
/**
* Registers and renders the hidden attachment detail admin page.
*
@@ -231,6 +233,10 @@ class AttachmentDetailPage {
/**
* Fetches and renders the external scan results for a given attachment.
*
+ * After rendering all per-provider sections, aggregates top_domains across
+ * all providers and passes them to render_domain_classification() for a
+ * consolidated view grouped by alert / other / ignored status.
+ *
* @param int $attachment_id Attachment post ID.
*
* @return void
@@ -241,7 +247,7 @@ class AttachmentDetailPage {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$rows = $wpdb->get_results(
$wpdb->prepare(
- "SELECT provider, match_count, raw_response, created_at
+ "SELECT provider, match_count, top_domains, raw_response, created_at
FROM {$wpdb->prefix}mra_external_results
WHERE attachment_id = %d
ORDER BY provider ASC",
@@ -261,6 +267,8 @@ class AttachmentDetailPage {
'picdefense' => 'PicDefense',
);
+ $merged_domains = array();
+
foreach ( $rows as $row ) {
if ( ! is_array( $row ) ) {
continue;
@@ -273,6 +281,9 @@ class AttachmentDetailPage {
$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 ) {
@@ -280,6 +291,19 @@ class AttachmentDetailPage {
$raw = is_array( $decoded ) ? $decoded : null;
}
+ // Accumulate top_domains across all providers.
+ foreach ( $domains as $domain => $count ) {
+ if ( ! is_string( $domain ) ) {
+ continue;
+ }
+ $c = is_numeric( $count ) ? (int) $count : 0;
+ if ( isset( $merged_domains[ $domain ] ) ) {
+ $merged_domains[ $domain ] += $c;
+ } else {
+ $merged_domains[ $domain ] = $c;
+ }
+ }
+
echo '
';
echo esc_html( $name );
echo ' — ';
@@ -311,6 +335,87 @@ class AttachmentDetailPage {
}
}
}
+
+ // Render consolidated domain classification section.
+ if ( ! empty( $merged_domains ) ) {
+ $this->render_domain_classification( $merged_domains );
+ }
+ }
+
+ /**
+ * Renders a domain classification summary grouped into Alert, Other, and Ignored sections.
+ *
+ * Each section is rendered only when non-empty. Alert domains are shown with
+ * a warning note about copyright risk.
+ *
+ * @param array $domains Map of domain => total occurrence count.
+ *
+ * @return void
+ */
+ private function render_domain_classification( array $domains ): void {
+ $alert_domains = array();
+ $other_domains = array();
+ $ignored_domains = array();
+
+ foreach ( $domains as $domain => $count ) {
+ $classification = HostnameFilter::classify( $domain );
+ if ( 'alert' === $classification ) {
+ $alert_domains[ $domain ] = $count;
+ } elseif ( 'ignored' === $classification ) {
+ $ignored_domains[ $domain ] = $count;
+ } else {
+ $other_domains[ $domain ] = $count;
+ }
+ }
+
+ if ( ! empty( $alert_domains ) ) {
+ echo '' . esc_html__( 'Alert Domains', 'robotstxt-mediaaudit' ) . '
';
+ echo '' . esc_html__( 'These domains are associated with copyright-protected content and may indicate a copyright risk.', 'robotstxt-mediaaudit' ) . '
';
+ echo '';
+ echo '';
+ echo '| ' . esc_html__( 'Domain', 'robotstxt-mediaaudit' ) . ' | ';
+ echo '' . esc_html__( 'Occurrences', 'robotstxt-mediaaudit' ) . ' | ';
+ echo '
';
+ foreach ( $alert_domains as $domain => $count ) {
+ echo '';
+ echo '| ' . esc_html( $domain ) . ' | ';
+ echo '' . esc_html( (string) $count ) . ' | ';
+ echo '
';
+ }
+ echo '
';
+ }
+
+ if ( ! empty( $other_domains ) ) {
+ echo '' . esc_html__( 'Other Domains', 'robotstxt-mediaaudit' ) . '
';
+ echo '';
+ echo '';
+ echo '| ' . esc_html__( 'Domain', 'robotstxt-mediaaudit' ) . ' | ';
+ echo '' . esc_html__( 'Occurrences', 'robotstxt-mediaaudit' ) . ' | ';
+ echo '
';
+ foreach ( $other_domains as $domain => $count ) {
+ echo '';
+ echo '| ' . esc_html( $domain ) . ' | ';
+ echo '' . esc_html( (string) $count ) . ' | ';
+ echo '
';
+ }
+ echo '
';
+ }
+
+ if ( ! empty( $ignored_domains ) ) {
+ echo '' . esc_html__( 'Ignored Domains', 'robotstxt-mediaaudit' ) . '
';
+ echo '';
+ echo '';
+ echo '| ' . esc_html__( 'Domain', 'robotstxt-mediaaudit' ) . ' | ';
+ echo '' . esc_html__( 'Occurrences', 'robotstxt-mediaaudit' ) . ' | ';
+ echo '
';
+ foreach ( $ignored_domains as $domain => $count ) {
+ echo '';
+ echo '| ' . esc_html( $domain ) . ' | ';
+ echo '' . esc_html( (string) $count ) . ' | ';
+ echo '
';
+ }
+ echo '
';
+ }
}
/**
diff --git a/includes/Admin/AuditPage.php b/includes/Admin/AuditPage.php
index 36070df..c66d61b 100644
--- a/includes/Admin/AuditPage.php
+++ b/includes/Admin/AuditPage.php
@@ -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 .= '';
if ( ! empty( $domains ) ) {
- $html .= '';
- $html .= '';
- $html .= '| ' . esc_html__( 'Domain', 'robotstxt-mediaaudit' ) . ' | ';
- $html .= '' . esc_html__( 'Occurrences', 'robotstxt-mediaaudit' ) . ' | ';
- $html .= '
';
+ $alert_domains = array();
+ $other_domains = array();
+
foreach ( $domains as $domain => $count ) {
if ( ! is_string( $domain ) ) {
continue;
}
- $html .= sprintf(
- '| %s | %s |
',
- 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 .= '';
+ $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 .= '
';
}
- $html .= '
';
}
}
@@ -452,6 +482,14 @@ class AuditPage {
);
}
+ if ( $stats['total_alert'] > 0 ) {
+ $this->stat_box(
+ $stats['total_alert'],
+ __( 'Alert', 'robotstxt-mediaaudit' ),
+ ''
+ );
+ }
+
echo '';
}
@@ -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 $ids Attachment IDs to export (empty = all).
+ * @param array $ids Attachment IDs to export (empty = all).
+ * @param bool $alerts_only Include only attachments with alert domains.
*
* @return array>
*/
- 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}>> $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 $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.
*
@@ -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;
+ }
}
diff --git a/includes/Admin/MediaListTable.php b/includes/Admin/MediaListTable.php
index ac4261a..cfcd18e 100644
--- a/includes/Admin/MediaListTable.php
+++ b/includes/Admin/MediaListTable.php
@@ -8,6 +8,7 @@
namespace MediaRightsAudit\Admin;
use MediaRightsAudit\Admin\AttachmentDetailPage;
+use MediaRightsAudit\External\HostnameFilter;
if ( ! class_exists( 'WP_List_Table' ) ) {
require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
@@ -83,7 +84,7 @@ class MediaListTable extends \WP_List_Table {
'run_external_scan' => __( 'Run External Scan', 'robotstxt-mediaaudit' ),
'purge_external_data' => __( 'Purge External Data', 'robotstxt-mediaaudit' ),
'export_csv' => __( 'Export CSV', 'robotstxt-mediaaudit' ),
- 'export_external_csv' => __( 'Export External Results CSV', 'robotstxt-mediaaudit' ),
+ 'export_csv_alerts' => __( 'Export CSV (Alerts only)', 'robotstxt-mediaaudit' ),
);
}
@@ -279,6 +280,49 @@ class MediaListTable extends \WP_List_Table {
$es_val = $item['external_status'];
$status = is_string( $es_val ) ? $es_val : '';
+ // Check for alert domains first.
+ $top_domains_raw = isset( $item['top_domains_data'] ) && is_array( $item['top_domains_data'] )
+ ? $item['top_domains_data']
+ : array();
+ $top_domains_data = array();
+ foreach ( $top_domains_raw as $td_key => $td_val ) {
+ if ( is_string( $td_key ) && is_int( $td_val ) ) {
+ $top_domains_data[ $td_key ] = $td_val;
+ }
+ }
+ $has_alert = ! empty( $top_domains_data ) && HostnameFilter::has_alert_domains( $top_domains_data );
+
+ if ( $has_alert ) {
+ $out = sprintf(
+ '%s',
+ esc_html__( 'Alert', 'robotstxt-mediaaudit' )
+ );
+
+ // Sum counts for non-ignored domains.
+ $filtered_count = 0;
+ foreach ( $top_domains_data as $domain => $count ) {
+ if ( ! HostnameFilter::is_ignored( $domain ) ) {
+ $filtered_count += $count;
+ }
+ }
+ if ( $filtered_count > 0 ) {
+ $out .= sprintf(
+ ' %s',
+ esc_html( number_format_i18n( $filtered_count ) )
+ );
+ }
+
+ $aid_val = $item['attachment_id'];
+ $aid = is_numeric( $aid_val ) ? (int) $aid_val : 0;
+ $out .= sprintf(
+ ' %s',
+ $aid,
+ esc_html__( 'View Results', 'robotstxt-mediaaudit' )
+ );
+
+ return $out;
+ }
+
$labels = array(
'pending' => __( 'Pending', 'robotstxt-mediaaudit' ),
'queued' => __( 'Queued', 'robotstxt-mediaaudit' ),
@@ -385,6 +429,11 @@ class MediaListTable extends \WP_List_Table {
// External status filter.
echo '