robotstxt-mediaaudit/includes/Admin/MediaListTable.php
2026-06-03 06:25:27 +00:00

617 lines
18 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;
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_external_csv' => __( 'Export External Results CSV', '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' )
),
);
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 : '';
$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>';
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;
$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
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 );
// 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 ) {
// 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}";
}
$count_sql = "SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_index i WHERE {$where}";
$items_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";
$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 ( ! $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 ) {
$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 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;
}
}