v1.6.0
This commit is contained in:
parent
83d629568a
commit
98b1cf0f3b
17 changed files with 2968 additions and 1313 deletions
|
|
@ -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 '<h3>';
|
||||
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<string, int> $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 '<h3>' . esc_html__( 'Alert Domains', 'robotstxt-mediaaudit' ) . '</h3>';
|
||||
echo '<p class="description">' . esc_html__( 'These domains are associated with copyright-protected content and may indicate a copyright risk.', 'robotstxt-mediaaudit' ) . '</p>';
|
||||
echo '<table class="widefat mra-detail-table">';
|
||||
echo '<thead><tr>';
|
||||
echo '<th>' . esc_html__( 'Domain', 'robotstxt-mediaaudit' ) . '</th>';
|
||||
echo '<th>' . esc_html__( 'Occurrences', 'robotstxt-mediaaudit' ) . '</th>';
|
||||
echo '</tr></thead><tbody>';
|
||||
foreach ( $alert_domains as $domain => $count ) {
|
||||
echo '<tr>';
|
||||
echo '<td>' . esc_html( $domain ) . '</td>';
|
||||
echo '<td>' . esc_html( (string) $count ) . '</td>';
|
||||
echo '</tr>';
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
}
|
||||
|
||||
if ( ! empty( $other_domains ) ) {
|
||||
echo '<h3>' . esc_html__( 'Other Domains', 'robotstxt-mediaaudit' ) . '</h3>';
|
||||
echo '<table class="widefat mra-detail-table">';
|
||||
echo '<thead><tr>';
|
||||
echo '<th>' . esc_html__( 'Domain', 'robotstxt-mediaaudit' ) . '</th>';
|
||||
echo '<th>' . esc_html__( 'Occurrences', 'robotstxt-mediaaudit' ) . '</th>';
|
||||
echo '</tr></thead><tbody>';
|
||||
foreach ( $other_domains as $domain => $count ) {
|
||||
echo '<tr>';
|
||||
echo '<td>' . esc_html( $domain ) . '</td>';
|
||||
echo '<td>' . esc_html( (string) $count ) . '</td>';
|
||||
echo '</tr>';
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
}
|
||||
|
||||
if ( ! empty( $ignored_domains ) ) {
|
||||
echo '<h3>' . esc_html__( 'Ignored Domains', 'robotstxt-mediaaudit' ) . '</h3>';
|
||||
echo '<table class="widefat mra-detail-table">';
|
||||
echo '<thead><tr>';
|
||||
echo '<th>' . esc_html__( 'Domain', 'robotstxt-mediaaudit' ) . '</th>';
|
||||
echo '<th>' . esc_html__( 'Occurrences', 'robotstxt-mediaaudit' ) . '</th>';
|
||||
echo '</tr></thead><tbody>';
|
||||
foreach ( $ignored_domains as $domain => $count ) {
|
||||
echo '<tr>';
|
||||
echo '<td>' . esc_html( $domain ) . '</td>';
|
||||
echo '<td>' . esc_html( (string) $count ) . '</td>';
|
||||
echo '</tr>';
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
'<span class="mra-badge mra-badge-alert">%s</span>',
|
||||
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(
|
||||
' <span class="mra-badge mra-badge-match-count">%s</span>',
|
||||
esc_html( number_format_i18n( $filtered_count ) )
|
||||
);
|
||||
}
|
||||
|
||||
$aid_val = $item['attachment_id'];
|
||||
$aid = is_numeric( $aid_val ) ? (int) $aid_val : 0;
|
||||
$out .= sprintf(
|
||||
' <a href="#" class="mra-view-details" data-id="%d">%s</a>',
|
||||
$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 '<select name="mra_external_status">';
|
||||
echo '<option value="">' . esc_html__( 'All statuses', 'robotstxt-mediaaudit' ) . '</option>';
|
||||
printf(
|
||||
'<option value="alert"%s>%s</option>',
|
||||
selected( $current_status, 'alert', false ),
|
||||
esc_html__( 'Alert', 'robotstxt-mediaaudit' )
|
||||
);
|
||||
foreach ( self::EXTERNAL_STATUSES as $s ) {
|
||||
printf(
|
||||
'<option value="%s"%s>%s</option>',
|
||||
|
|
@ -456,6 +505,13 @@ class MediaListTable extends \WP_List_Table {
|
|||
$show_unused = ! empty( $_REQUEST['mra_unused'] );
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
||||
|
||||
// Detect alert filter before normalising external_status.
|
||||
$filter_alert = ( 'alert' === $external_status );
|
||||
if ( $filter_alert ) {
|
||||
// Reuse existing matches WHERE logic to narrow the candidate set.
|
||||
$external_status = 'matches';
|
||||
}
|
||||
|
||||
if ( ! in_array( $external_status, self::EXTERNAL_STATUSES, true ) ) {
|
||||
$external_status = '';
|
||||
}
|
||||
|
|
@ -485,8 +541,6 @@ class MediaListTable extends \WP_List_Table {
|
|||
|
||||
$where = implode( ' AND ', $where_parts );
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||
|
||||
if ( 'usage_count' === $orderby ) {
|
||||
$order_sql = "(SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_usage mu WHERE mu.attachment_id = i.attachment_id) {$order}";
|
||||
} elseif ( 'external_scanned_at' === $orderby ) {
|
||||
|
|
@ -496,30 +550,110 @@ class MediaListTable extends \WP_List_Table {
|
|||
$order_sql = "i.attachment_id {$order}";
|
||||
}
|
||||
|
||||
$count_sql = "SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_index i WHERE {$where}";
|
||||
$items_sql = "SELECT i.*,
|
||||
$select_sql = "SELECT i.*,
|
||||
(SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_usage mu WHERE mu.attachment_id = i.attachment_id) AS usage_count,
|
||||
(SELECT COALESCE(SUM(er.match_count), 0) FROM {$wpdb->prefix}mra_external_results er WHERE er.attachment_id = i.attachment_id) AS total_match_count
|
||||
FROM {$wpdb->prefix}mra_media_index i
|
||||
WHERE {$where}
|
||||
ORDER BY {$order_sql}
|
||||
LIMIT %d OFFSET %d";
|
||||
FROM {$wpdb->prefix}mra_media_index i";
|
||||
|
||||
$count_prepared = empty( $prepare_args )
|
||||
? $count_sql
|
||||
: $wpdb->prepare( $count_sql, ...$prepare_args );
|
||||
$rows = array();
|
||||
$total = 0;
|
||||
|
||||
$items_prepared = $wpdb->prepare(
|
||||
$items_sql,
|
||||
...array_merge( $prepare_args, array( $per_page, $offset ) )
|
||||
);
|
||||
if ( $filter_alert ) {
|
||||
// Fetch ALL matching rows (no LIMIT), then PHP-filter by alert domains.
|
||||
$all_sql = $select_sql . " WHERE {$where} ORDER BY {$order_sql}";
|
||||
|
||||
$total = (int) $wpdb->get_var( $count_prepared );
|
||||
$rows = $wpdb->get_results( $items_prepared, ARRAY_A );
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||
$all_prepared = empty( $prepare_args )
|
||||
? $all_sql
|
||||
: $wpdb->prepare( $all_sql, ...$prepare_args );
|
||||
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||
$all_rows = $wpdb->get_results( $all_prepared, ARRAY_A );
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||
|
||||
if ( ! $rows ) {
|
||||
if ( ! is_array( $all_rows ) ) {
|
||||
$all_rows = array();
|
||||
}
|
||||
|
||||
// Bulk-fetch top_domains for all candidate rows.
|
||||
$all_ids = array_map( 'intval', array_column( $all_rows, 'attachment_id' ) );
|
||||
$top_domains_by_id = self::fetch_top_domains( $all_ids );
|
||||
|
||||
// PHP-filter: keep only rows that have at least one alert domain.
|
||||
$filtered = array();
|
||||
foreach ( $all_rows as $r ) {
|
||||
if ( ! is_array( $r ) ) {
|
||||
continue;
|
||||
}
|
||||
$aid_val = $r['attachment_id'] ?? null;
|
||||
$aid = is_numeric( $aid_val ) ? (int) $aid_val : 0;
|
||||
if ( HostnameFilter::has_alert_domains( $top_domains_by_id[ $aid ] ?? array() ) ) {
|
||||
$filtered[] = $r;
|
||||
}
|
||||
}
|
||||
|
||||
$total = count( $filtered );
|
||||
$rows = array_slice( $filtered, $offset, $per_page );
|
||||
|
||||
// Attach top_domains_data for the page slice.
|
||||
foreach ( $rows as &$row ) {
|
||||
$rid_val = $row['attachment_id'] ?? null;
|
||||
$rid = is_numeric( $rid_val ) ? (int) $rid_val : 0;
|
||||
$row['top_domains_data'] = $top_domains_by_id[ $rid ] ?? array();
|
||||
}
|
||||
unset( $row );
|
||||
} else {
|
||||
$count_sql = "SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_index i WHERE {$where}";
|
||||
$items_sql = $select_sql . " WHERE {$where} ORDER BY {$order_sql} LIMIT %d OFFSET %d";
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||
$count_prepared = empty( $prepare_args )
|
||||
? $count_sql
|
||||
: $wpdb->prepare( $count_sql, ...$prepare_args );
|
||||
|
||||
$items_prepared = $wpdb->prepare(
|
||||
$items_sql,
|
||||
...array_merge( $prepare_args, array( $per_page, $offset ) )
|
||||
);
|
||||
|
||||
$total = (int) $wpdb->get_var( $count_prepared );
|
||||
$rows = $wpdb->get_results( $items_prepared, ARRAY_A );
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||
|
||||
if ( ! is_array( $rows ) ) {
|
||||
$rows = array();
|
||||
}
|
||||
|
||||
// Bulk-fetch top_domains only for rows with external_status = 'matches'.
|
||||
$matches_ids = array();
|
||||
foreach ( $rows as $r ) {
|
||||
if ( ! is_array( $r ) ) {
|
||||
continue;
|
||||
}
|
||||
$es_val = $r['external_status'] ?? null;
|
||||
if ( 'matches' === $es_val ) {
|
||||
$aid_val = $r['attachment_id'] ?? null;
|
||||
if ( is_numeric( $aid_val ) ) {
|
||||
$matches_ids[] = (int) $aid_val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$top_domains_by_id = empty( $matches_ids )
|
||||
? array()
|
||||
: self::fetch_top_domains( $matches_ids );
|
||||
|
||||
foreach ( $rows as &$row ) {
|
||||
if ( ! is_array( $row ) ) {
|
||||
continue;
|
||||
}
|
||||
$rid_val = $row['attachment_id'] ?? null;
|
||||
$rid = is_numeric( $rid_val ) ? (int) $rid_val : 0;
|
||||
$row['top_domains_data'] = $top_domains_by_id[ $rid ] ?? array();
|
||||
}
|
||||
unset( $row );
|
||||
}
|
||||
|
||||
if ( empty( $rows ) ) {
|
||||
$this->items = array();
|
||||
} else {
|
||||
// Bulk-load usages for this page (avoid N+1).
|
||||
|
|
@ -533,6 +667,9 @@ class MediaListTable extends \WP_List_Table {
|
|||
}
|
||||
|
||||
foreach ( $rows as &$row ) {
|
||||
if ( ! is_array( $row ) ) {
|
||||
continue;
|
||||
}
|
||||
$rid_val = $row['attachment_id'] ?? null;
|
||||
$rid = is_numeric( $rid_val ) ? (int) $rid_val : 0;
|
||||
$row['usages_data'] = $usage_by[ $rid ] ?? array();
|
||||
|
|
@ -562,6 +699,74 @@ class MediaListTable extends \WP_List_Table {
|
|||
// Public query helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetches and merges top_domains for a set of attachment IDs.
|
||||
* Multiple providers for the same attachment have their domain counts summed.
|
||||
*
|
||||
* @param array<int, int> $attachment_ids Attachment IDs to look up.
|
||||
*
|
||||
* @return array<int, array<string, int>> attachment_id → domain → count
|
||||
*/
|
||||
public static function fetch_top_domains( array $attachment_ids ): array {
|
||||
global $wpdb;
|
||||
|
||||
if ( empty( $attachment_ids ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$placeholders = implode( ',', array_fill( 0, count( $attachment_ids ), '%d' ) );
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT attachment_id, top_domains FROM {$wpdb->prefix}mra_external_results WHERE attachment_id IN ({$placeholders})",
|
||||
...$attachment_ids
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
|
||||
|
||||
if ( ! is_array( $rows ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$result = 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( $result[ $aid ] ) ) {
|
||||
$result[ $aid ] = array();
|
||||
}
|
||||
|
||||
foreach ( $domains as $domain => $count ) {
|
||||
if ( ! is_string( $domain ) ) {
|
||||
continue;
|
||||
}
|
||||
$c = is_numeric( $count ) ? (int) $count : 0;
|
||||
if ( isset( $result[ $aid ][ $domain ] ) ) {
|
||||
$result[ $aid ][ $domain ] += $c;
|
||||
} else {
|
||||
$result[ $aid ][ $domain ] = $c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches usage rows for a set of attachment IDs in one query.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -8,9 +8,11 @@
|
|||
namespace MediaRightsAudit\Admin;
|
||||
|
||||
/**
|
||||
* Registers and renders the plugin Settings page.
|
||||
* Registers and renders the plugin Settings page with tab navigation.
|
||||
*
|
||||
* Settings are stored in a single option array: robotstxt_mediaaudit_settings.
|
||||
* All tabs share a single option (robotstxt_mediaaudit_settings). A hidden
|
||||
* _tab sentinel in each form submission tells sanitize() which fields to
|
||||
* process, so saving one tab never clears another tab's data.
|
||||
*/
|
||||
class Settings {
|
||||
|
||||
|
|
@ -24,6 +26,198 @@ class Settings {
|
|||
*/
|
||||
const OPTION_NAME = 'robotstxt_mediaaudit_settings';
|
||||
|
||||
/**
|
||||
* Default alert hostnames seeded on first activation.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const DEFAULT_FILTER_INCLUDE = array(
|
||||
'123rf.com',
|
||||
'afp.com',
|
||||
'alamy.com',
|
||||
'arcangel.com',
|
||||
'artgrid.io',
|
||||
'audioblocks.com',
|
||||
'auroraphotos.com',
|
||||
'backgridusa.com',
|
||||
'bigstockphoto.com',
|
||||
'blendimages.com',
|
||||
'bridgemanimages.com',
|
||||
'canstockphoto.com',
|
||||
'cavanimages.com',
|
||||
'creativemarket.com',
|
||||
'crestock.com',
|
||||
'cultura-rm.com',
|
||||
'depositphotos.com',
|
||||
'designbundles.net',
|
||||
'diomedia.com',
|
||||
'dreamstime.com',
|
||||
'elements.envato.com',
|
||||
'epa.eu',
|
||||
'filmsupply.com',
|
||||
'flaticon.com',
|
||||
'fotolia.com',
|
||||
'freepik.com',
|
||||
'gallerystock.com',
|
||||
'gettyimages.com',
|
||||
'goffphotos.com',
|
||||
'granger.com',
|
||||
'graphicstock.com',
|
||||
'imago-images.de',
|
||||
'istockphoto.com',
|
||||
'maryevans.com',
|
||||
'mindenpictures.com',
|
||||
'mintimages.com',
|
||||
'mostphotos.com',
|
||||
'naturepl.com',
|
||||
'newsroom.ap.org',
|
||||
'nhpa.co.uk',
|
||||
'offset.shutterstock.com',
|
||||
'pacificpressagency.com',
|
||||
'panthermedia.net',
|
||||
'photoshot.com',
|
||||
'pictures.reuters.com',
|
||||
'pixtastock.com',
|
||||
'plainpicture.com',
|
||||
'pond5.com',
|
||||
'robertharding.com',
|
||||
'sciencephoto.com',
|
||||
'shutterstock.com',
|
||||
'splashnews.com',
|
||||
'stock.adobe.com',
|
||||
'stocksy.com',
|
||||
'storyblocks.com',
|
||||
'trevillion.com',
|
||||
'vecteezy.com',
|
||||
'videoblocks.com',
|
||||
'wenn.com',
|
||||
'yayimages.com',
|
||||
'zumapress.com',
|
||||
);
|
||||
|
||||
/**
|
||||
* Default ignored hostnames seeded on first activation.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const DEFAULT_FILTER_EXCLUDE = array(
|
||||
'9gag.com',
|
||||
'artstation.com',
|
||||
'bandcamp.com',
|
||||
'behance.net',
|
||||
'blogger.com',
|
||||
'bsky.app',
|
||||
'dailymotion.com',
|
||||
'deviantart.com',
|
||||
'discord.com',
|
||||
'douyin.com',
|
||||
'dribbble.com',
|
||||
'facebook.com',
|
||||
'fb.com',
|
||||
'flickr.com',
|
||||
'gfycat.com',
|
||||
'giphy.com',
|
||||
'imgur.com',
|
||||
'instagram.com',
|
||||
'linkedin.com',
|
||||
'livejournal.com',
|
||||
'loom.com',
|
||||
'mastodon.social',
|
||||
'medium.com',
|
||||
'notion.site',
|
||||
'onlyfans.com',
|
||||
'patreon.com',
|
||||
'periscope.tv',
|
||||
'pinterest.com',
|
||||
'qq.com',
|
||||
'reddit.com',
|
||||
'snapchat.com',
|
||||
'soundcloud.com',
|
||||
'spotify.com',
|
||||
'squarespace.com',
|
||||
'streamable.com',
|
||||
'substack.com',
|
||||
'telegram.org',
|
||||
'tenor.com',
|
||||
'threads.net',
|
||||
'tiktok.com',
|
||||
'tumblr.com',
|
||||
'twitch.tv',
|
||||
'twitter.com',
|
||||
'vimeo.com',
|
||||
'vk.com',
|
||||
'weebly.com',
|
||||
'weibo.com',
|
||||
'whatsapp.com',
|
||||
'wix.com',
|
||||
'wordpress.com',
|
||||
'wordpress.org',
|
||||
'x.com',
|
||||
'xiaohongshu.com',
|
||||
'youtube.com',
|
||||
'zhihu.com',
|
||||
);
|
||||
|
||||
/**
|
||||
* Valid tab keys (order defines display order).
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const TAB_KEYS = array( 'general', 'api', 'filters', 'external' );
|
||||
|
||||
/**
|
||||
* Seeds the default filter lists into the option when they have not been set yet.
|
||||
*
|
||||
* Called once on plugin activation. Does nothing if the keys already exist,
|
||||
* so existing user customisations are never overwritten.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function maybe_seed_defaults(): void {
|
||||
$raw = get_option( self::OPTION_NAME, array() );
|
||||
$options = is_array( $raw ) ? $raw : array();
|
||||
$changed = false;
|
||||
|
||||
if ( ! array_key_exists( 'filter_include', $options ) ) {
|
||||
$options['filter_include'] = self::DEFAULT_FILTER_INCLUDE;
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
if ( ! array_key_exists( 'filter_exclude', $options ) ) {
|
||||
$options['filter_exclude'] = self::DEFAULT_FILTER_EXCLUDE;
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
if ( $changed ) {
|
||||
update_option( self::OPTION_NAME, $options );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns translated tab labels keyed by tab ID.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function get_tabs(): array {
|
||||
return array(
|
||||
'general' => __( 'General', 'robotstxt-mediaaudit' ),
|
||||
'api' => __( 'API Credentials', 'robotstxt-mediaaudit' ),
|
||||
'filters' => __( 'Filters', 'robotstxt-mediaaudit' ),
|
||||
'external' => __( 'External Scanning', 'robotstxt-mediaaudit' ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the active tab key derived from the current request.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function get_active_tab(): string {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
$raw = isset( $_GET['tab'] ) && is_string( $_GET['tab'] ) ? sanitize_key( $_GET['tab'] ) : 'general';
|
||||
return in_array( $raw, self::TAB_KEYS, true ) ? $raw : 'general';
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers all settings, sections, and fields via the WordPress Settings API.
|
||||
*
|
||||
|
|
@ -42,33 +236,35 @@ class Settings {
|
|||
)
|
||||
);
|
||||
|
||||
// --- General tab ---
|
||||
add_settings_section(
|
||||
'mra_general',
|
||||
__( 'General', 'robotstxt-mediaaudit' ),
|
||||
'__return_false',
|
||||
'robotstxt-mediaaudit-settings'
|
||||
'',
|
||||
array( $this, 'section_general_status' ),
|
||||
'mra-settings-general'
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
'delete_on_uninstall',
|
||||
__( 'Delete data on uninstall', 'robotstxt-mediaaudit' ),
|
||||
array( $this, 'field_delete_on_uninstall' ),
|
||||
'robotstxt-mediaaudit-settings',
|
||||
'mra-settings-general',
|
||||
'mra_general'
|
||||
);
|
||||
|
||||
// --- API Credentials tab ---
|
||||
add_settings_section(
|
||||
'mra_api_credentials',
|
||||
__( 'API Credentials', 'robotstxt-mediaaudit' ),
|
||||
'',
|
||||
'__return_false',
|
||||
'robotstxt-mediaaudit-settings'
|
||||
'mra-settings-api'
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
'google_vision_api_key',
|
||||
__( 'Google Cloud Vision API Key', 'robotstxt-mediaaudit' ),
|
||||
array( $this, 'field_google_vision_api_key' ),
|
||||
'robotstxt-mediaaudit-settings',
|
||||
'mra-settings-api',
|
||||
'mra_api_credentials'
|
||||
);
|
||||
|
||||
|
|
@ -76,7 +272,7 @@ class Settings {
|
|||
'tineye_api_key',
|
||||
__( 'TinEye API Key', 'robotstxt-mediaaudit' ),
|
||||
array( $this, 'field_tineye_api_key' ),
|
||||
'robotstxt-mediaaudit-settings',
|
||||
'mra-settings-api',
|
||||
'mra_api_credentials'
|
||||
);
|
||||
|
||||
|
|
@ -84,7 +280,7 @@ class Settings {
|
|||
'picdefense_user_id',
|
||||
__( 'PicDefense User ID', 'robotstxt-mediaaudit' ),
|
||||
array( $this, 'field_picdefense_user_id' ),
|
||||
'robotstxt-mediaaudit-settings',
|
||||
'mra-settings-api',
|
||||
'mra_api_credentials'
|
||||
);
|
||||
|
||||
|
|
@ -92,22 +288,54 @@ class Settings {
|
|||
'picdefense_api_key',
|
||||
__( 'PicDefense API Key', 'robotstxt-mediaaudit' ),
|
||||
array( $this, 'field_picdefense_api_key' ),
|
||||
'robotstxt-mediaaudit-settings',
|
||||
'mra-settings-api',
|
||||
'mra_api_credentials'
|
||||
);
|
||||
|
||||
// --- Filters tab ---
|
||||
add_settings_section(
|
||||
'mra_filters_include',
|
||||
__( 'Alert Hostnames', 'robotstxt-mediaaudit' ),
|
||||
array( $this, 'section_filters_include' ),
|
||||
'mra-settings-filters'
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
'filter_include',
|
||||
__( 'Hostnames', 'robotstxt-mediaaudit' ),
|
||||
array( $this, 'field_filter_include' ),
|
||||
'mra-settings-filters',
|
||||
'mra_filters_include'
|
||||
);
|
||||
|
||||
add_settings_section(
|
||||
'mra_filters_exclude',
|
||||
__( 'Ignored Hostnames', 'robotstxt-mediaaudit' ),
|
||||
array( $this, 'section_filters_exclude' ),
|
||||
'mra-settings-filters'
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
'filter_exclude',
|
||||
__( 'Hostnames', 'robotstxt-mediaaudit' ),
|
||||
array( $this, 'field_filter_exclude' ),
|
||||
'mra-settings-filters',
|
||||
'mra_filters_exclude'
|
||||
);
|
||||
|
||||
// --- External Scanning tab ---
|
||||
add_settings_section(
|
||||
'mra_external_scanning',
|
||||
__( 'External Scanning', 'robotstxt-mediaaudit' ),
|
||||
'',
|
||||
'__return_false',
|
||||
'robotstxt-mediaaudit-settings'
|
||||
'mra-settings-external'
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
'external_batch_size',
|
||||
__( 'Batch Size', 'robotstxt-mediaaudit' ),
|
||||
array( $this, 'field_external_batch_size' ),
|
||||
'robotstxt-mediaaudit-settings',
|
||||
'mra-settings-external',
|
||||
'mra_external_scanning'
|
||||
);
|
||||
|
||||
|
|
@ -115,7 +343,7 @@ class Settings {
|
|||
'rate_limit_per_minute',
|
||||
__( 'Rate Limit (requests/min)', 'robotstxt-mediaaudit' ),
|
||||
array( $this, 'field_rate_limit_per_minute' ),
|
||||
'robotstxt-mediaaudit-settings',
|
||||
'mra-settings-external',
|
||||
'mra_external_scanning'
|
||||
);
|
||||
}
|
||||
|
|
@ -123,42 +351,214 @@ class Settings {
|
|||
/**
|
||||
* Sanitises the settings array on save.
|
||||
*
|
||||
* Reads the submitted _tab sentinel to determine which fields are present
|
||||
* and merges them onto the existing stored values, leaving all other tabs
|
||||
* untouched.
|
||||
*
|
||||
* @param mixed $input Raw POST input.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function sanitize( $input ): array {
|
||||
$output = array();
|
||||
$raw = get_option( self::OPTION_NAME, array() );
|
||||
$current = is_array( $raw ) ? $raw : array();
|
||||
$output = $current;
|
||||
|
||||
if ( ! is_array( $input ) ) {
|
||||
return $output;
|
||||
}
|
||||
|
||||
$output['delete_on_uninstall'] = ! empty( $input['delete_on_uninstall'] );
|
||||
$tab_raw = $input['_tab'] ?? '';
|
||||
$tab = is_string( $tab_raw ) ? sanitize_key( $tab_raw ) : '';
|
||||
|
||||
$key_val = $input['google_vision_api_key'] ?? null;
|
||||
$output['google_vision_api_key'] = is_string( $key_val ) ? sanitize_text_field( $key_val ) : '';
|
||||
if ( 'general' === $tab ) {
|
||||
$output['delete_on_uninstall'] = ! empty( $input['delete_on_uninstall'] );
|
||||
}
|
||||
|
||||
$tineye_val = $input['tineye_api_key'] ?? null;
|
||||
$output['tineye_api_key'] = is_string( $tineye_val ) ? sanitize_text_field( $tineye_val ) : '';
|
||||
if ( 'api' === $tab ) {
|
||||
$key_val = $input['google_vision_api_key'] ?? null;
|
||||
$output['google_vision_api_key'] = is_string( $key_val ) ? sanitize_text_field( $key_val ) : '';
|
||||
|
||||
$pd_uid_val = $input['picdefense_user_id'] ?? null;
|
||||
$output['picdefense_user_id'] = is_string( $pd_uid_val ) ? sanitize_text_field( $pd_uid_val ) : '';
|
||||
$tineye_val = $input['tineye_api_key'] ?? null;
|
||||
$output['tineye_api_key'] = is_string( $tineye_val ) ? sanitize_text_field( $tineye_val ) : '';
|
||||
|
||||
$pd_key_val = $input['picdefense_api_key'] ?? null;
|
||||
$output['picdefense_api_key'] = is_string( $pd_key_val ) ? sanitize_text_field( $pd_key_val ) : '';
|
||||
$pd_uid_val = $input['picdefense_user_id'] ?? null;
|
||||
$output['picdefense_user_id'] = is_string( $pd_uid_val ) ? sanitize_text_field( $pd_uid_val ) : '';
|
||||
|
||||
$batch_val = $input['external_batch_size'] ?? null;
|
||||
$batch = is_numeric( $batch_val ) ? (int) $batch_val : 10;
|
||||
$output['external_batch_size'] = max( 1, min( 100, $batch ) );
|
||||
$pd_key_val = $input['picdefense_api_key'] ?? null;
|
||||
$output['picdefense_api_key'] = is_string( $pd_key_val ) ? sanitize_text_field( $pd_key_val ) : '';
|
||||
}
|
||||
|
||||
$rl_val = $input['rate_limit_per_minute'] ?? null;
|
||||
$rl = is_numeric( $rl_val ) ? (int) $rl_val : 10;
|
||||
$output['rate_limit_per_minute'] = max( 1, min( 60, $rl ) );
|
||||
if ( 'filters' === $tab ) {
|
||||
$output['filter_include'] = $this->sanitize_hostname_list( $input['filter_include'] ?? '' );
|
||||
$output['filter_exclude'] = $this->sanitize_hostname_list( $input['filter_exclude'] ?? '' );
|
||||
|
||||
$overlap = array_intersect( $output['filter_include'], $output['filter_exclude'] );
|
||||
if ( ! empty( $overlap ) ) {
|
||||
$output['filter_exclude'] = array_values( array_diff( $output['filter_exclude'], $overlap ) );
|
||||
add_settings_error(
|
||||
self::OPTION_NAME,
|
||||
'mra_filter_overlap',
|
||||
sprintf(
|
||||
/* translators: %s: comma-separated list of hostnames */
|
||||
__( 'The following hostnames were removed from the exclusion list because they already appear in the alert list: %s', 'robotstxt-mediaaudit' ),
|
||||
esc_html( implode( ', ', $overlap ) )
|
||||
),
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ( 'external' === $tab ) {
|
||||
$batch_val = $input['external_batch_size'] ?? null;
|
||||
$batch = is_numeric( $batch_val ) ? (int) $batch_val : 10;
|
||||
$output['external_batch_size'] = max( 1, min( 100, $batch ) );
|
||||
|
||||
$rl_val = $input['rate_limit_per_minute'] ?? null;
|
||||
$rl = is_numeric( $rl_val ) ? (int) $rl_val : 10;
|
||||
$output['rate_limit_per_minute'] = max( 1, min( 60, $rl ) );
|
||||
}
|
||||
|
||||
unset( $output['_tab'] );
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitises a newline-separated list of hostnames into a deduplicated array.
|
||||
*
|
||||
* Accepts plain hostnames (example.com) and explicit wildcard prefixes
|
||||
* (*.example.com). A plain hostname implicitly covers all its subdomains
|
||||
* at match time.
|
||||
*
|
||||
* @param mixed $raw Raw textarea value.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function sanitize_hostname_list( mixed $raw ): array {
|
||||
if ( ! is_string( $raw ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$split = preg_split( '/\r?\n/', $raw );
|
||||
$lines = is_array( $split ) ? $split : array();
|
||||
$result = array();
|
||||
|
||||
foreach ( $lines as $line ) {
|
||||
$h = strtolower( sanitize_text_field( trim( $line ) ) );
|
||||
if ( '' === $h ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Preserve wildcard prefix before stripping URL parts.
|
||||
$prefix = '';
|
||||
if ( str_starts_with( $h, '*.' ) ) {
|
||||
$prefix = '*.';
|
||||
$h = substr( $h, 2 );
|
||||
}
|
||||
|
||||
// Strip scheme (http://, https://, etc.).
|
||||
$stripped = preg_replace( '/^[a-z][a-z0-9+\-.]*:\/\//', '', $h );
|
||||
if ( is_string( $stripped ) ) {
|
||||
$h = $stripped;
|
||||
}
|
||||
|
||||
// Strip path, query string, and fragment — keep only host[:port].
|
||||
$h = substr( $h, 0, strcspn( $h, '/?#' ) );
|
||||
|
||||
// Strip port number.
|
||||
$no_port = preg_replace( '/:\d+$/', '', $h );
|
||||
if ( is_string( $no_port ) ) {
|
||||
$h = $no_port;
|
||||
}
|
||||
|
||||
$h = trim( $h, '. ' );
|
||||
|
||||
// Strip www. prefix — example.com already covers www.example.com at match time.
|
||||
if ( str_starts_with( $h, 'www.' ) ) {
|
||||
$h = substr( $h, 4 );
|
||||
}
|
||||
|
||||
$h = $prefix . $h;
|
||||
|
||||
if ( preg_match( '/^(\*\.)?[a-z0-9][a-z0-9\-]*(\.[a-z0-9][a-z0-9\-]*)+$/', $h ) ) {
|
||||
$result[] = $h;
|
||||
}
|
||||
}
|
||||
|
||||
$result = array_values( array_unique( $result ) );
|
||||
sort( $result );
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the API credentials status block shown at the top of the General tab.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function section_general_status(): void {
|
||||
$gv_ok = $this->is_google_vision_configured();
|
||||
$te_ok = $this->is_tineye_configured();
|
||||
$pd_ok = $this->is_picdefense_configured();
|
||||
?>
|
||||
<div class="mra-provider-status">
|
||||
<strong><?php esc_html_e( 'API Credentials Status', 'robotstxt-mediaaudit' ); ?></strong>
|
||||
<ul>
|
||||
<li>
|
||||
<?php if ( $gv_ok ) : ?>
|
||||
<span class="dashicons dashicons-yes-alt" style="color:green;"></span>
|
||||
<?php else : ?>
|
||||
<span class="dashicons dashicons-warning" style="color:orange;"></span>
|
||||
<?php endif; ?>
|
||||
<?php esc_html_e( 'Google Cloud Vision', 'robotstxt-mediaaudit' ); ?>
|
||||
</li>
|
||||
<li>
|
||||
<?php if ( $te_ok ) : ?>
|
||||
<span class="dashicons dashicons-yes-alt" style="color:green;"></span>
|
||||
<?php else : ?>
|
||||
<span class="dashicons dashicons-warning" style="color:orange;"></span>
|
||||
<?php endif; ?>
|
||||
<?php esc_html_e( 'TinEye', 'robotstxt-mediaaudit' ); ?>
|
||||
</li>
|
||||
<li>
|
||||
<?php if ( $pd_ok ) : ?>
|
||||
<span class="dashicons dashicons-yes-alt" style="color:green;"></span>
|
||||
<?php else : ?>
|
||||
<span class="dashicons dashicons-warning" style="color:orange;"></span>
|
||||
<?php endif; ?>
|
||||
PicDefense
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the description for the "Alert Hostnames" filter section.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function section_filters_include(): void {
|
||||
?>
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'Matches from these hostnames are flagged as critical copyright alerts. Entering example.com also covers all its subdomains.', 'robotstxt-mediaaudit' ); ?>
|
||||
</p>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the description for the "Ignored Hostnames" filter section.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function section_filters_exclude(): void {
|
||||
?>
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'Matches from these hostnames are silently ignored and not counted as potential copyright issues. Entering example.com also covers all its subdomains. A hostname already present in the alert list cannot be added here.', 'robotstxt-mediaaudit' ); ?>
|
||||
</p>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the "Delete data on uninstall" checkbox field.
|
||||
*
|
||||
|
|
@ -339,6 +739,68 @@ class Settings {
|
|||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the alert hostname filter textarea.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function field_filter_include(): void {
|
||||
$options = (array) get_option( self::OPTION_NAME, array() );
|
||||
$raw = $options['filter_include'] ?? array();
|
||||
$strings = array();
|
||||
if ( is_array( $raw ) ) {
|
||||
foreach ( $raw as $item ) {
|
||||
if ( is_string( $item ) ) {
|
||||
$strings[] = $item;
|
||||
}
|
||||
}
|
||||
}
|
||||
$value = implode( "\n", $strings );
|
||||
?>
|
||||
<textarea
|
||||
id="filter_include"
|
||||
name="<?php echo esc_attr( self::OPTION_NAME ); ?>[filter_include]"
|
||||
rows="8"
|
||||
class="large-text code"
|
||||
placeholder="example.com"
|
||||
><?php echo esc_textarea( $value ); ?></textarea>
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'One hostname per line. example.com covers all its subdomains. Explicit wildcard: *.example.com.', 'robotstxt-mediaaudit' ); ?>
|
||||
</p>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the ignored hostname filter textarea.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function field_filter_exclude(): void {
|
||||
$options = (array) get_option( self::OPTION_NAME, array() );
|
||||
$raw = $options['filter_exclude'] ?? array();
|
||||
$strings = array();
|
||||
if ( is_array( $raw ) ) {
|
||||
foreach ( $raw as $item ) {
|
||||
if ( is_string( $item ) ) {
|
||||
$strings[] = $item;
|
||||
}
|
||||
}
|
||||
}
|
||||
$value = implode( "\n", $strings );
|
||||
?>
|
||||
<textarea
|
||||
id="filter_exclude"
|
||||
name="<?php echo esc_attr( self::OPTION_NAME ); ?>[filter_exclude]"
|
||||
rows="8"
|
||||
class="large-text code"
|
||||
placeholder="cdn.example.com"
|
||||
><?php echo esc_textarea( $value ); ?></textarea>
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'One hostname per line. example.com covers all its subdomains. Explicit wildcard: *.example.com.', 'robotstxt-mediaaudit' ); ?>
|
||||
</p>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the external batch size field.
|
||||
*
|
||||
|
|
@ -436,49 +898,31 @@ class Settings {
|
|||
wp_die( esc_html__( 'You do not have permission to access this page.', 'robotstxt-mediaaudit' ) );
|
||||
}
|
||||
|
||||
$gv_ok = $this->is_google_vision_configured();
|
||||
$te_ok = $this->is_tineye_configured();
|
||||
$pd_ok = $this->is_picdefense_configured();
|
||||
$tab = $this->get_active_tab();
|
||||
$tabs = $this->get_tabs();
|
||||
$page_url = admin_url( 'admin.php?page=robotstxt-mediaaudit-settings' );
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
|
||||
|
||||
<div class="mra-provider-status">
|
||||
<strong><?php esc_html_e( 'API Credentials Status', 'robotstxt-mediaaudit' ); ?></strong>
|
||||
<ul>
|
||||
<li>
|
||||
<?php if ( $gv_ok ) : ?>
|
||||
<span class="dashicons dashicons-yes-alt" style="color:green;"></span>
|
||||
<?php else : ?>
|
||||
<span class="dashicons dashicons-warning" style="color:orange;"></span>
|
||||
<?php endif; ?>
|
||||
<?php esc_html_e( 'Google Cloud Vision', 'robotstxt-mediaaudit' ); ?>
|
||||
</li>
|
||||
<li>
|
||||
<?php if ( $te_ok ) : ?>
|
||||
<span class="dashicons dashicons-yes-alt" style="color:green;"></span>
|
||||
<?php else : ?>
|
||||
<span class="dashicons dashicons-warning" style="color:orange;"></span>
|
||||
<?php endif; ?>
|
||||
<?php esc_html_e( 'TinEye', 'robotstxt-mediaaudit' ); ?>
|
||||
</li>
|
||||
<li>
|
||||
<?php if ( $pd_ok ) : ?>
|
||||
<span class="dashicons dashicons-yes-alt" style="color:green;"></span>
|
||||
<?php else : ?>
|
||||
<span class="dashicons dashicons-warning" style="color:orange;"></span>
|
||||
<?php endif; ?>
|
||||
PicDefense
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<nav class="nav-tab-wrapper">
|
||||
<?php foreach ( $tabs as $key => $label ) : ?>
|
||||
<a
|
||||
href="<?php echo esc_url( add_query_arg( 'tab', $key, $page_url ) ); ?>"
|
||||
class="nav-tab<?php echo ( $tab === $key ) ? ' nav-tab-active' : ''; ?>"
|
||||
><?php echo esc_html( $label ); ?></a>
|
||||
<?php endforeach; ?>
|
||||
</nav>
|
||||
|
||||
<form method="post" action="options.php">
|
||||
<?php
|
||||
settings_fields( self::OPTION_GROUP );
|
||||
do_settings_sections( 'robotstxt-mediaaudit-settings' );
|
||||
submit_button();
|
||||
?>
|
||||
<?php settings_fields( self::OPTION_GROUP ); ?>
|
||||
<input
|
||||
type="hidden"
|
||||
name="<?php echo esc_attr( self::OPTION_NAME ); ?>[_tab]"
|
||||
value="<?php echo esc_attr( $tab ); ?>"
|
||||
/>
|
||||
<?php do_settings_sections( 'mra-settings-' . $tab ); ?>
|
||||
<?php submit_button(); ?>
|
||||
</form>
|
||||
</div>
|
||||
<?php
|
||||
|
|
|
|||
Loading…
Reference in a new issue