robotstxt-mediaaudit/includes/Admin/MediaListTable.php
2026-06-03 06:30:49 +00:00

852 lines
26 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* WP_List_Table subclass for the Media Audit list.
*
* @package MediaRightsAudit\Admin
*/
namespace MediaRightsAudit\Admin;
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 '&mdash;';
}
$ts = strtotime( $raw );
if ( false === $ts ) {
return '&mdash;';
}
$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;
}
}