856 lines
26 KiB
PHP
856 lines
26 KiB
PHP
<?php
|
||
/**
|
||
* WP_List_Table subclass for the Media Audit list.
|
||
*
|
||
* @package MediaRightsAudit\Admin
|
||
*/
|
||
|
||
namespace MediaRightsAudit\Admin;
|
||
|
||
if ( ! defined( 'ABSPATH' ) ) {
|
||
exit;
|
||
}
|
||
|
||
use MediaRightsAudit\Admin\AlertsPage;
|
||
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';
|
||
}
|
||
|
||
/**
|
||
* Displays the indexed media library with usage data.
|
||
*
|
||
* Columns: thumbnail, filename, attachment_id, usage_count, usages, external_status.
|
||
*/
|
||
class MediaListTable extends \WP_List_Table {
|
||
|
||
/**
|
||
* Allowed values for the external_status column.
|
||
*/
|
||
const EXTERNAL_STATUSES = array( 'pending', 'queued', 'scanned', 'matches', 'error' );
|
||
|
||
/**
|
||
* Initialises the list table with singular/plural labels and AJAX support.
|
||
*/
|
||
public function __construct() {
|
||
parent::__construct(
|
||
array(
|
||
'singular' => 'mra-attachment',
|
||
'plural' => 'mra-attachments',
|
||
'ajax' => false,
|
||
)
|
||
);
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// WP_List_Table interface
|
||
// -------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Returns all column headers.
|
||
*
|
||
* @return array<string, string>
|
||
*/
|
||
public function get_columns() {
|
||
return array(
|
||
'cb' => '<input type="checkbox" />',
|
||
'thumbnail' => __( 'Thumbnail', 'robotstxt-mediaaudit' ),
|
||
'filename' => __( 'Filename', 'robotstxt-mediaaudit' ),
|
||
'attachment_id' => __( 'ID', 'robotstxt-mediaaudit' ),
|
||
'usage_count' => __( 'Usage Count', 'robotstxt-mediaaudit' ),
|
||
'usages' => __( 'Usages', 'robotstxt-mediaaudit' ),
|
||
'external_status' => __( 'External Status', 'robotstxt-mediaaudit' ),
|
||
'external_scanned_at' => __( 'Last Scanned', 'robotstxt-mediaaudit' ),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Returns sortable columns.
|
||
*
|
||
* @return array<string, array<int, mixed>>
|
||
*/
|
||
protected function get_sortable_columns() {
|
||
return array(
|
||
'attachment_id' => array( 'attachment_id', true ),
|
||
'usage_count' => array( 'usage_count', false ),
|
||
'external_scanned_at' => array( 'external_scanned_at', false ),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Returns bulk actions.
|
||
*
|
||
* @return array<string, string>
|
||
*/
|
||
protected function get_bulk_actions() {
|
||
return array(
|
||
'run_external_scan' => __( 'Run External Scan', 'robotstxt-mediaaudit' ),
|
||
'purge_external_data' => __( 'Purge External Data', 'robotstxt-mediaaudit' ),
|
||
'export_csv' => __( 'Export CSV', 'robotstxt-mediaaudit' ),
|
||
'export_csv_alerts' => __( 'Export CSV (Alerts only)', 'robotstxt-mediaaudit' ),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Fallback renderer for unrecognised columns.
|
||
*
|
||
* @param array<string, mixed> $item Row data.
|
||
* @param string $column_name Column slug.
|
||
*
|
||
* @return string
|
||
*/
|
||
protected function column_default( $item, $column_name ) {
|
||
return '';
|
||
}
|
||
|
||
/**
|
||
* Renders the checkbox column.
|
||
*
|
||
* @param array<string, mixed> $item Row data.
|
||
*
|
||
* @return string
|
||
*/
|
||
protected function column_cb( $item ) {
|
||
$aid = $item['attachment_id'];
|
||
return sprintf(
|
||
'<input type="checkbox" name="attachment_id[]" value="%d" />',
|
||
is_numeric( $aid ) ? (int) $aid : 0
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Renders a 40×40 thumbnail linked to the attachment.
|
||
*
|
||
* @param array<string, mixed> $item Row data.
|
||
*
|
||
* @return string
|
||
*/
|
||
protected function column_thumbnail( $item ) {
|
||
$aid_val = $item['attachment_id'];
|
||
$url_val = $item['file_url'];
|
||
$id = is_numeric( $aid_val ) ? (int) $aid_val : 0;
|
||
$url = esc_url( is_string( $url_val ) ? $url_val : '' );
|
||
|
||
$thumb = wp_get_attachment_image(
|
||
$id,
|
||
array( 40, 40 ),
|
||
false,
|
||
array( 'class' => 'mra-thumb' )
|
||
);
|
||
|
||
if ( ! $thumb ) {
|
||
$thumb = '<span class="dashicons dashicons-format-image" style="font-size:40px;line-height:1;"></span>';
|
||
}
|
||
|
||
return sprintf( '<a href="%s" target="_blank">%s</a>', $url, $thumb );
|
||
}
|
||
|
||
/**
|
||
* Renders the filename column with a link to the original file.
|
||
*
|
||
* @param array<string, mixed> $item Row data.
|
||
*
|
||
* @return string
|
||
*/
|
||
protected function column_filename( $item ) {
|
||
$aid_val = $item['attachment_id'];
|
||
$fn_val = $item['file_name'];
|
||
$url_val = $item['file_url'];
|
||
$id = is_numeric( $aid_val ) ? (int) $aid_val : 0;
|
||
$filename = esc_html( is_string( $fn_val ) ? $fn_val : '' );
|
||
$url = esc_url( is_string( $url_val ) ? $url_val : '' );
|
||
|
||
$title = sprintf(
|
||
'<a href="%s" target="_blank">%s</a>',
|
||
$url,
|
||
( '' !== $filename ? $filename : '—' )
|
||
);
|
||
|
||
$raw_edit_link = get_edit_post_link( $id );
|
||
$actions = array(
|
||
'edit' => sprintf(
|
||
'<a href="%s">%s</a>',
|
||
esc_url( is_string( $raw_edit_link ) ? $raw_edit_link : '' ),
|
||
__( 'Edit', 'robotstxt-mediaaudit' )
|
||
),
|
||
'view_details' => sprintf(
|
||
'<a href="#" class="mra-view-details" data-id="%d">%s</a>',
|
||
$id,
|
||
__( 'View Details', 'robotstxt-mediaaudit' )
|
||
),
|
||
'full_report' => sprintf(
|
||
'<a href="%s">%s</a>',
|
||
esc_url( AttachmentDetailPage::url( $id ) ),
|
||
__( 'Full Report', 'robotstxt-mediaaudit' )
|
||
),
|
||
);
|
||
|
||
return $title . $this->row_actions( $actions );
|
||
}
|
||
|
||
/**
|
||
* Renders the attachment ID column.
|
||
*
|
||
* @param array<string, mixed> $item Row data.
|
||
*
|
||
* @return string
|
||
*/
|
||
protected function column_attachment_id( $item ) {
|
||
$aid_val = $item['attachment_id'];
|
||
return esc_html( (string) ( is_numeric( $aid_val ) ? (int) $aid_val : 0 ) );
|
||
}
|
||
|
||
/**
|
||
* Renders the usage count column.
|
||
*
|
||
* @param array<string, mixed> $item Row data.
|
||
*
|
||
* @return string
|
||
*/
|
||
protected function column_usage_count( $item ) {
|
||
$cnt_val = $item['usage_count'];
|
||
$count = is_numeric( $cnt_val ) ? (int) $cnt_val : 0;
|
||
if ( 0 === $count ) {
|
||
return '<span style="color:#999">' . esc_html__( 'Unused', 'robotstxt-mediaaudit' ) . '</span>';
|
||
}
|
||
return esc_html( (string) $count );
|
||
}
|
||
|
||
/**
|
||
* Renders the usages column with inline post links (max 3) and a modal link for more.
|
||
*
|
||
* @param array<string, mixed> $item Row data.
|
||
*
|
||
* @return string
|
||
*/
|
||
protected function column_usages( $item ) {
|
||
$aid_val = $item['attachment_id'];
|
||
$cnt_val = $item['usage_count'];
|
||
$id = is_numeric( $aid_val ) ? (int) $aid_val : 0;
|
||
$count = is_numeric( $cnt_val ) ? (int) $cnt_val : 0;
|
||
|
||
if ( 0 === $count ) {
|
||
return '—';
|
||
}
|
||
|
||
$usages = isset( $item['usages_data'] ) && is_array( $item['usages_data'] )
|
||
? $item['usages_data']
|
||
: array();
|
||
|
||
$shown = array_slice( $usages, 0, 3 );
|
||
$html = '<ul class="mra-usage-list">';
|
||
foreach ( $shown as $u ) {
|
||
if ( ! is_array( $u ) ) {
|
||
continue;
|
||
}
|
||
$pid_raw = $u['post_id'] ?? 0;
|
||
$post_id = is_numeric( $pid_raw ) ? (int) $pid_raw : 0;
|
||
$t = $u['post_title'] ?? '';
|
||
$raw_title = is_string( $t ) ? $t : '';
|
||
$post_title = esc_html( '' !== $raw_title ? $raw_title : __( '(no title)', 'robotstxt-mediaaudit' ) );
|
||
$edit_link = get_edit_post_link( $post_id );
|
||
if ( $edit_link ) {
|
||
$html .= sprintf( '<li><a href="%s">%s</a></li>', esc_url( $edit_link ), $post_title );
|
||
} else {
|
||
$html .= '<li>' . $post_title . '</li>';
|
||
}
|
||
}
|
||
|
||
if ( $count > 3 ) {
|
||
$html .= sprintf(
|
||
'<li><a href="#" class="mra-view-details" data-id="%d">%s</a></li>',
|
||
$id,
|
||
sprintf(
|
||
/* translators: %d: number of additional usages */
|
||
esc_html__( '+ %d more…', 'robotstxt-mediaaudit' ),
|
||
$count - 3
|
||
)
|
||
);
|
||
}
|
||
|
||
$html .= '</ul>';
|
||
return $html;
|
||
}
|
||
|
||
/**
|
||
* Renders the external status badge.
|
||
*
|
||
* @param array<string, mixed> $item Row data.
|
||
*
|
||
* @return string
|
||
*/
|
||
protected function column_external_status( $item ) {
|
||
$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 );
|
||
$is_dismissed = ! empty( $item['is_dismissed'] );
|
||
|
||
if ( $has_alert && $is_dismissed ) {
|
||
$aid_val = $item['attachment_id'];
|
||
$aid = is_numeric( $aid_val ) ? (int) $aid_val : 0;
|
||
$out = sprintf(
|
||
'<span class="mra-badge mra-badge-dismissed">%s</span>',
|
||
esc_html__( 'Dismissed', 'robotstxt-mediaaudit' )
|
||
);
|
||
$out .= sprintf(
|
||
' <a href="#" class="mra-view-details" data-id="%d">%s</a>',
|
||
$aid,
|
||
esc_html__( 'View Results', 'robotstxt-mediaaudit' )
|
||
);
|
||
return $out;
|
||
}
|
||
|
||
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' ),
|
||
'scanned' => __( 'Scanned', 'robotstxt-mediaaudit' ),
|
||
'matches' => __( 'Matches Found', 'robotstxt-mediaaudit' ),
|
||
'error' => __( 'Error', 'robotstxt-mediaaudit' ),
|
||
);
|
||
|
||
$label = isset( $labels[ $status ] ) ? $labels[ $status ] : ucfirst( $status );
|
||
|
||
$out = sprintf(
|
||
'<span class="mra-badge mra-badge-%s">%s</span>',
|
||
esc_attr( $status ),
|
||
esc_html( $label )
|
||
);
|
||
|
||
if ( 'matches' === $status ) {
|
||
$mc_val = $item['total_match_count'] ?? null;
|
||
$match_count = is_numeric( $mc_val ) ? (int) $mc_val : 0;
|
||
if ( $match_count > 0 ) {
|
||
$out .= sprintf(
|
||
' <span class="mra-badge mra-badge-match-count">%s</span>',
|
||
esc_html( number_format_i18n( $match_count ) )
|
||
);
|
||
}
|
||
}
|
||
|
||
if ( in_array( $status, array( 'scanned', 'matches' ), true ) ) {
|
||
$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;
|
||
}
|
||
|
||
/**
|
||
* Renders the external scan date column.
|
||
*
|
||
* Shows a human-readable time difference (e.g. "3 days ago") with the full
|
||
* date/time as a tooltip. Displays "—" when the attachment has never been scanned.
|
||
*
|
||
* @param array<string, mixed> $item Row data.
|
||
*
|
||
* @return string
|
||
*/
|
||
protected function column_external_scanned_at( $item ) {
|
||
$raw = $item['external_scanned_at'];
|
||
|
||
if ( ! is_string( $raw ) || '' === $raw ) {
|
||
return '—';
|
||
}
|
||
|
||
$ts = strtotime( $raw );
|
||
if ( false === $ts ) {
|
||
return '—';
|
||
}
|
||
|
||
$date_fmt = get_option( 'date_format' );
|
||
$time_fmt = get_option( 'time_format' );
|
||
$format = ( is_string( $date_fmt ) ? $date_fmt : 'Y-m-d' )
|
||
. ' '
|
||
. ( is_string( $time_fmt ) ? $time_fmt : 'H:i' );
|
||
$full_val = wp_date( $format, $ts );
|
||
$full = is_string( $full_val ) ? $full_val : $raw;
|
||
|
||
return sprintf(
|
||
'<abbr title="%s">%s</abbr>',
|
||
esc_attr( $full ),
|
||
sprintf(
|
||
/* translators: %s: human-readable time difference, e.g. "3 days" */
|
||
esc_html__( '%s ago', 'robotstxt-mediaaudit' ),
|
||
esc_html( human_time_diff( $ts ) )
|
||
)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Renders filter controls above the table.
|
||
*
|
||
* @param string $which 'top' or 'bottom'.
|
||
*
|
||
* @return void
|
||
*/
|
||
protected function extra_tablenav( $which ) {
|
||
if ( 'top' !== $which ) {
|
||
return;
|
||
}
|
||
|
||
// phpcs:disable WordPress.Security.NonceVerification.Recommended
|
||
$current_status = isset( $_REQUEST['mra_external_status'] ) && is_string( $_REQUEST['mra_external_status'] ) ? sanitize_key( wp_unslash( $_REQUEST['mra_external_status'] ) ) : '';
|
||
$current_post_type = isset( $_REQUEST['mra_post_type'] ) && is_string( $_REQUEST['mra_post_type'] ) ? sanitize_key( wp_unslash( $_REQUEST['mra_post_type'] ) ) : '';
|
||
$show_unused = ! empty( $_REQUEST['mra_unused'] );
|
||
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
||
|
||
$post_types = self::get_used_post_types();
|
||
|
||
echo '<div class="alignleft actions mra-filters">';
|
||
|
||
// 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>',
|
||
esc_attr( $s ),
|
||
selected( $current_status, $s, false ),
|
||
esc_html( ucfirst( $s ) )
|
||
);
|
||
}
|
||
echo '</select>';
|
||
|
||
// Post type filter.
|
||
if ( $post_types ) {
|
||
echo '<select name="mra_post_type">';
|
||
echo '<option value="">' . esc_html__( 'All post types', 'robotstxt-mediaaudit' ) . '</option>';
|
||
foreach ( $post_types as $pt ) {
|
||
printf(
|
||
'<option value="%s"%s>%s</option>',
|
||
esc_attr( $pt ),
|
||
selected( $current_post_type, $pt, false ),
|
||
esc_html( $pt )
|
||
);
|
||
}
|
||
echo '</select>';
|
||
}
|
||
|
||
// Unused toggle.
|
||
echo '<label style="margin-left:8px">';
|
||
echo '<input type="checkbox" name="mra_unused" value="1"' . checked( $show_unused, true, false ) . ' /> ';
|
||
echo esc_html__( 'Unused only', 'robotstxt-mediaaudit' );
|
||
echo '</label>';
|
||
|
||
submit_button( __( 'Filter', 'robotstxt-mediaaudit' ), 'button', 'mra_filter', false );
|
||
echo '</div>';
|
||
}
|
||
|
||
/**
|
||
* Message shown when the index is empty.
|
||
*
|
||
* @return void
|
||
*/
|
||
public function no_items() {
|
||
esc_html_e( 'No indexed attachments found. Run wp mra scan-internal to populate the index.', 'robotstxt-mediaaudit' );
|
||
}
|
||
|
||
/**
|
||
* Queries the database and sets $this->items.
|
||
*
|
||
* @return void
|
||
*/
|
||
public function prepare_items() {
|
||
global $wpdb;
|
||
|
||
$dismissed_ids = AlertsPage::get_dismissed_ids();
|
||
|
||
$per_page = 20;
|
||
$paged = $this->get_pagenum();
|
||
$offset = ( $paged - 1 ) * $per_page;
|
||
|
||
// Sorting.
|
||
$allowed_orderby = array( 'attachment_id', 'usage_count', 'external_scanned_at' );
|
||
// phpcs:disable WordPress.Security.NonceVerification.Recommended
|
||
$orderby_raw = isset( $_REQUEST['orderby'] ) && is_string( $_REQUEST['orderby'] ) ? sanitize_key( wp_unslash( $_REQUEST['orderby'] ) ) : '';
|
||
$orderby = in_array( $orderby_raw, $allowed_orderby, true ) ? $orderby_raw : 'attachment_id';
|
||
$order_raw = strtoupper( isset( $_REQUEST['order'] ) && is_string( $_REQUEST['order'] ) ? sanitize_key( wp_unslash( $_REQUEST['order'] ) ) : '' );
|
||
$order = ( 'DESC' === $order_raw ) ? 'DESC' : 'ASC';
|
||
|
||
// Filters.
|
||
$search = isset( $_REQUEST['s'] ) && is_string( $_REQUEST['s'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['s'] ) ) : '';
|
||
$external_status = isset( $_REQUEST['mra_external_status'] ) && is_string( $_REQUEST['mra_external_status'] ) ? sanitize_key( wp_unslash( $_REQUEST['mra_external_status'] ) ) : '';
|
||
$filter_post_type = isset( $_REQUEST['mra_post_type'] ) && is_string( $_REQUEST['mra_post_type'] ) ? sanitize_key( wp_unslash( $_REQUEST['mra_post_type'] ) ) : '';
|
||
$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 ) {
|
||
// Do not restrict by external_status: attachments can have alert domains
|
||
// even when external_status is not 'matches'. The PHP pass below handles
|
||
// the real filter, consistent with get_alert_attachment_count().
|
||
$external_status = '';
|
||
}
|
||
|
||
if ( ! in_array( $external_status, self::EXTERNAL_STATUSES, true ) ) {
|
||
$external_status = '';
|
||
}
|
||
|
||
// Build WHERE clause.
|
||
$where_parts = array( '1=1' );
|
||
$prepare_args = array();
|
||
|
||
if ( '' !== $search ) {
|
||
$where_parts[] = 'i.file_name LIKE %s';
|
||
$prepare_args[] = '%' . $wpdb->esc_like( $search ) . '%';
|
||
}
|
||
|
||
if ( '' !== $external_status ) {
|
||
$where_parts[] = 'i.external_status = %s';
|
||
$prepare_args[] = $external_status;
|
||
}
|
||
|
||
if ( '' !== $filter_post_type ) {
|
||
$where_parts[] = 'EXISTS (SELECT 1 FROM ' . $wpdb->prefix . 'mra_media_usage mu WHERE mu.attachment_id = i.attachment_id AND mu.post_type = %s)';
|
||
$prepare_args[] = $filter_post_type;
|
||
}
|
||
|
||
if ( $show_unused ) {
|
||
$where_parts[] = 'NOT EXISTS (SELECT 1 FROM ' . $wpdb->prefix . 'mra_media_usage mu WHERE mu.attachment_id = i.attachment_id)';
|
||
}
|
||
|
||
$where = implode( ' AND ', $where_parts );
|
||
|
||
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 ) {
|
||
// NULLs always last, regardless of sort direction.
|
||
$order_sql = "i.external_scanned_at IS NULL ASC, i.external_scanned_at {$order}";
|
||
} else {
|
||
$order_sql = "i.attachment_id {$order}";
|
||
}
|
||
|
||
$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";
|
||
|
||
$rows = array();
|
||
$total = 0;
|
||
|
||
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}";
|
||
|
||
// 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 );
|
||
|
||
$all_rows = $wpdb->get_results( $all_prepared, ARRAY_A );
|
||
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||
|
||
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 and are not dismissed.
|
||
$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() ) && ! isset( $dismissed_ids[ $aid ] ) ) {
|
||
$filtered[] = $r;
|
||
}
|
||
}
|
||
|
||
$total = count( $filtered );
|
||
$rows = array_slice( $filtered, $offset, $per_page );
|
||
|
||
// Attach top_domains_data and is_dismissed 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();
|
||
$row['is_dismissed'] = isset( $dismissed_ids[ $rid ] );
|
||
}
|
||
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();
|
||
$row['is_dismissed'] = isset( $dismissed_ids[ $rid ] );
|
||
}
|
||
unset( $row );
|
||
}
|
||
|
||
if ( empty( $rows ) ) {
|
||
$this->items = array();
|
||
} else {
|
||
// Bulk-load usages for this page (avoid N+1).
|
||
$ids = array_map( 'intval', array_column( $rows, 'attachment_id' ) );
|
||
$usages = self::fetch_usages( $ids );
|
||
$usage_by = array();
|
||
foreach ( $usages as $u ) {
|
||
$uid_val = $u['attachment_id'] ?? null;
|
||
$uid = is_numeric( $uid_val ) ? (int) $uid_val : 0;
|
||
$usage_by[ $uid ][] = $u;
|
||
}
|
||
|
||
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();
|
||
}
|
||
unset( $row );
|
||
|
||
$this->items = $rows;
|
||
}
|
||
|
||
$this->_column_headers = array(
|
||
$this->get_columns(),
|
||
array(),
|
||
$this->get_sortable_columns(),
|
||
'filename',
|
||
);
|
||
|
||
$this->set_pagination_args(
|
||
array(
|
||
'total_items' => $total,
|
||
'total_pages' => (int) ceil( $total / $per_page ),
|
||
'per_page' => $per_page,
|
||
)
|
||
);
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// 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.
|
||
*
|
||
* @param array<int, int> $attachment_ids Attachment IDs to look up.
|
||
*
|
||
* @return list<array<array-key, mixed>>
|
||
*/
|
||
public static function fetch_usages( 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 u.attachment_id, u.post_id, u.post_type, u.context, u.meta_key,
|
||
p.post_title, p.post_status
|
||
FROM {$wpdb->prefix}mra_media_usage u
|
||
JOIN {$wpdb->posts} p ON u.post_id = p.ID
|
||
WHERE u.attachment_id IN ({$placeholders})
|
||
ORDER BY u.attachment_id ASC, u.post_id ASC",
|
||
...$attachment_ids
|
||
),
|
||
ARRAY_A
|
||
);
|
||
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
|
||
|
||
return $rows ?? array();
|
||
}
|
||
|
||
/**
|
||
* Returns distinct post types that have at least one usage record.
|
||
*
|
||
* @return array<int, string>
|
||
*/
|
||
public static function get_used_post_types(): array {
|
||
global $wpdb;
|
||
|
||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||
$rows = $wpdb->get_col(
|
||
"SELECT DISTINCT post_type FROM {$wpdb->prefix}mra_media_usage ORDER BY post_type ASC"
|
||
);
|
||
|
||
if ( ! is_array( $rows ) ) {
|
||
return array();
|
||
}
|
||
|
||
$types = array();
|
||
foreach ( $rows as $r ) {
|
||
if ( is_string( $r ) ) {
|
||
$types[] = $r;
|
||
}
|
||
}
|
||
return $types;
|
||
}
|
||
}
|