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

843 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
/**
* Admin page controller for the Media Audit list view.
*
* @package MediaRightsAudit\Admin
*/
namespace MediaRightsAudit\Admin;
use MediaRightsAudit\External\ExternalScanner;
use MediaRightsAudit\External\ResultsConsolidator;
use MediaRightsAudit\Internal\AttachmentIndexer;
use MediaRightsAudit\Internal\UsageScanner;
/**
* Registers and renders the main Media Audit admin page.
*
* Responsibilities:
* - Enqueue CSS/JS on the plugin's admin pages.
* - Render the page: dashboard stats + WP_List_Table.
* - Handle CSV bulk-action export (before page output).
* - Serve AJAX usage-detail modal content.
*/
class AuditPage {
/**
* Hook suffix assigned to the main menu page (populated on admin_menu).
*
* @var string
*/
private string $hook_suffix = '';
/**
* Registers all hooks owned by this controller.
*
* @return void
*/
public function register_hooks(): void {
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
add_action( 'wp_ajax_mra_usage_details', array( $this, 'ajax_usage_details' ) );
}
/**
* Stores the hook suffix so asset enqueuing can limit to this page.
*
* @param string $suffix Hook suffix from add_menu_page / add_submenu_page.
*
* @return void
*/
public function set_hook_suffix( string $suffix ): void {
$this->hook_suffix = $suffix;
add_action( 'load-' . $suffix, array( $this, 'handle_load' ) );
}
/**
* Fires before the admin header is sent, allowing headers to be set.
*
* Handles bulk actions that need to modify headers (CSV export) or redirect
* (run_external_scan, purge_external_data) before any HTML output begins.
*
* @return void
*/
public function handle_load(): void {
$action = $this->get_current_bulk_action();
if ( 'export_csv' === $action ) {
$this->handle_export_csv();
} elseif ( 'export_external_csv' === $action ) {
$this->handle_export_external_csv();
} elseif ( 'run_external_scan' === $action ) {
$this->handle_run_external_scan();
} elseif ( 'purge_external_data' === $action ) {
$this->handle_purge_external_data();
}
}
/**
* Enqueues CSS and JS only on the Media Audit admin page.
*
* @param string $hook_suffix Current admin page hook suffix.
*
* @return void
*/
public function enqueue_assets( string $hook_suffix ): void {
if ( '' !== $this->hook_suffix && $hook_suffix !== $this->hook_suffix ) {
return;
}
$base = ROBOTSTXT_MEDIAAUDIT_PLUGIN_URL . 'assets/';
$ver = ROBOTSTXT_MEDIAAUDIT_VERSION;
wp_enqueue_style(
'mra-admin',
$base . 'css/media-audit-admin.css',
array(),
$ver
);
wp_enqueue_script(
'mra-admin',
$base . 'js/media-audit-admin.js',
array( 'jquery' ),
$ver,
true
);
wp_localize_script(
'mra-admin',
'mraAdmin',
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'mra_usage_details' ),
'i18n' => array(
'loading' => __( 'Loading…', 'robotstxt-mediaaudit' ),
'errorLoading' => __( 'Error loading details.', 'robotstxt-mediaaudit' ),
'noUsages' => __( 'This image is not used in any post.', 'robotstxt-mediaaudit' ),
'detailsTitle' => __( 'Usage Details', 'robotstxt-mediaaudit' ),
),
)
);
}
/**
* Renders the full Media Audit admin page.
*
* Bulk actions (CSV export, external scan, purge) are intercepted earlier in
* handle_load() via the load-{hook_suffix} action, before any HTML is sent.
*
* @return void
*/
public function render(): void {
if ( ! current_user_can( 'edit_posts' ) ) {
wp_die( esc_html__( 'You do not have permission to access this page.', 'robotstxt-mediaaudit' ) );
}
$table = new MediaListTable();
$table->prepare_items();
echo '<div class="wrap">';
echo '<h1>' . esc_html__( 'Media Audit', 'robotstxt-mediaaudit' ) . '</h1>';
$this->render_dashboard_stats();
echo '<form method="get">';
echo '<input type="hidden" name="page" value="robotstxt-mediaaudit" />';
$table->search_box( __( 'Search', 'robotstxt-mediaaudit' ), 'mra-search' );
$table->display();
echo '</form>';
// Modal overlay (hidden by default, opened via JS).
echo '<div id="mra-modal-overlay" class="mra-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="mra-modal-title">';
echo ' <div class="mra-modal">';
echo ' <div class="mra-modal-header">';
echo ' <h3 id="mra-modal-title" class="mra-modal-title">' . esc_html__( 'Usage Details', 'robotstxt-mediaaudit' ) . '</h3>';
echo ' <button type="button" class="mra-modal-close" aria-label="' . esc_attr__( 'Close', 'robotstxt-mediaaudit' ) . '">&times;</button>';
echo ' </div>';
echo ' <div class="mra-modal-body"></div>';
echo ' </div>';
echo '</div>';
echo '</div>'; // .wrap
}
/**
* AJAX handler: returns formatted HTML for the usage-detail modal.
*
* @return void
*/
public function ajax_usage_details(): void {
check_ajax_referer( 'mra_usage_details', 'nonce' );
if ( ! current_user_can( 'edit_posts' ) ) {
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'robotstxt-mediaaudit' ) ) );
}
$attachment_id = isset( $_POST['attachment_id'] ) && is_string( $_POST['attachment_id'] ) ? (int) sanitize_text_field( wp_unslash( $_POST['attachment_id'] ) ) : 0;
if ( $attachment_id <= 0 ) {
wp_send_json_error( array( 'message' => __( 'Invalid attachment ID.', 'robotstxt-mediaaudit' ) ) );
}
$usages = MediaListTable::fetch_usages( array( $attachment_id ) );
$title = get_the_title( $attachment_id );
// Build usage section.
$context_labels = array(
'featured' => __( 'Featured Image', 'robotstxt-mediaaudit' ),
'content' => __( 'Post Content', 'robotstxt-mediaaudit' ),
'meta' => __( 'Custom Field', 'robotstxt-mediaaudit' ),
);
if ( empty( $usages ) ) {
$usage_html = '<p>' . esc_html__( 'This image is not used in any post.', 'robotstxt-mediaaudit' ) . '</p>';
} else {
$usage_html = '<table class="widefat mra-usages-table">';
$usage_html .= '<thead><tr>';
$usage_html .= '<th>' . esc_html__( 'Post', 'robotstxt-mediaaudit' ) . '</th>';
$usage_html .= '<th>' . esc_html__( 'Type', 'robotstxt-mediaaudit' ) . '</th>';
$usage_html .= '<th>' . esc_html__( 'Context', 'robotstxt-mediaaudit' ) . '</th>';
$usage_html .= '<th>' . esc_html__( 'Status', 'robotstxt-mediaaudit' ) . '</th>';
$usage_html .= '</tr></thead><tbody>';
foreach ( $usages as $u ) {
$pid_val = $u['post_id'] ?? null;
$post_id = is_numeric( $pid_val ) ? (int) $pid_val : 0;
$t_raw = $u['post_title'] ?? '';
$post_title = is_string( $t_raw ) && '' !== $t_raw ? $t_raw : sprintf( '#%d', $post_id );
$pt_val = $u['post_type'] ?? null;
$post_type = is_string( $pt_val ) ? $pt_val : '';
$ctx_val = $u['context'] ?? null;
$context = is_string( $ctx_val ) ? $ctx_val : '';
$status_val = $u['post_status'] ?? null;
$status = is_string( $status_val ) ? $status_val : '';
$mk_val = $u['meta_key'] ?? null;
$meta_key = is_string( $mk_val ) ? $mk_val : '';
$ctx_label = isset( $context_labels[ $context ] ) ? $context_labels[ $context ] : esc_html( $context );
if ( '' !== $meta_key ) {
$ctx_label .= ' <code>' . esc_html( $meta_key ) . '</code>';
}
$edit_link = get_edit_post_link( $post_id );
$post_cell = $edit_link
? sprintf( '<a href="%s">%s</a>', esc_url( $edit_link ), esc_html( $post_title ) )
: esc_html( $post_title );
$usage_html .= '<tr>';
$usage_html .= '<td>' . $post_cell . '</td>';
$usage_html .= '<td>' . esc_html( $post_type ) . '</td>';
$usage_html .= '<td>' . $ctx_label . '</td>';
$usage_html .= '<td>' . esc_html( $status ) . '</td>';
$usage_html .= '</tr>';
}
$usage_html .= '</tbody></table>';
}
// Build external results section.
$external_html = $this->build_external_results_html( $attachment_id );
// Combine sections; add headings only when both sections are present.
if ( '' !== $external_html ) {
$html = '<h4 class="mra-section-heading">' . esc_html__( 'Internal Usage', 'robotstxt-mediaaudit' ) . '</h4>';
$html .= $usage_html;
$html .= $external_html;
} else {
$html = $usage_html;
}
wp_send_json_success(
array(
'title' => esc_html( $title ? $title : sprintf( '#%d', $attachment_id ) ),
'html' => $html,
)
);
}
/**
* Builds the HTML for the external scan results section of the detail modal.
*
* Returns an empty string when no external scan data exists for the attachment.
*
* @param int $attachment_id WordPress attachment ID.
*
* @return string
*/
private function build_external_results_html( int $attachment_id ): string {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT provider, match_count, top_domains, created_at
FROM {$wpdb->prefix}mra_external_results
WHERE attachment_id = %d
ORDER BY provider ASC",
$attachment_id
),
ARRAY_A
);
if ( empty( $rows ) ) {
return '';
}
$provider_names = array(
'google_vision' => 'Google Cloud Vision',
'tineye' => 'TinEye',
);
$html = '<h4 class="mra-section-heading">' . esc_html__( 'External Scan Results', 'robotstxt-mediaaudit' ) . '</h4>';
foreach ( $rows as $row ) {
if ( ! is_array( $row ) ) {
continue;
}
$slug_val = $row['provider'] ?? null;
$slug = is_string( $slug_val ) ? $slug_val : '';
$name = isset( $provider_names[ $slug ] ) ? $provider_names[ $slug ] : ucwords( str_replace( '_', ' ', $slug ) );
$mc_val = $row['match_count'] ?? null;
$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();
$html .= '<div class="mra-provider-result">';
$html .= '<strong>' . esc_html( $name ) . '</strong> — ';
if ( $match_count > 0 ) {
$html .= esc_html(
sprintf(
/* translators: %d: number of external matches */
_n( '%d match', '%d matches', $match_count, 'robotstxt-mediaaudit' ),
$match_count
)
);
} else {
$html .= esc_html__( 'No matches', 'robotstxt-mediaaudit' );
}
if ( '' !== $scanned_at ) {
$html .= ' <small>' . esc_html( $scanned_at ) . '</small>';
}
$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>';
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 ) )
);
}
$html .= '</tbody></table>';
}
}
// Consensus section: only when multiple providers have results.
if ( count( $rows ) > 1 ) {
$consensus = ResultsConsolidator::get_consensus_domains( $attachment_id );
if ( ! empty( $consensus ) ) {
$html .= '<div class="mra-consensus">';
$html .= '<strong>' . esc_html__( 'Consensus Domains', 'robotstxt-mediaaudit' ) . '</strong>';
$html .= '<ul class="mra-usage-list">';
foreach ( $consensus as $domain => $count ) {
$html .= '<li>' . esc_html( $domain ) . ' <small>(' . esc_html(
sprintf(
/* translators: %d: number of providers that agree */
_n( '%d provider', '%d providers', $count, 'robotstxt-mediaaudit' ),
$count
)
) . ')</small></li>';
}
$html .= '</ul></div>';
}
}
return $html;
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
/**
* Renders the dashboard stats strip.
*
* @return void
*/
private function render_dashboard_stats(): void {
$stats = self::get_dashboard_stats();
$scanned_pct = $stats['total_indexed'] > 0
? round( $stats['total_scanned'] / $stats['total_indexed'] * 100 )
: 0;
echo '<div class="mra-stats">';
$this->stat_box(
$stats['total_indexed'],
__( 'In Index', 'robotstxt-mediaaudit' ),
$stats['pending_index'] > 0
? sprintf(
/* translators: %d: count of images not yet indexed */
__( '%d not yet indexed', 'robotstxt-mediaaudit' ),
$stats['pending_index']
)
: __( 'All indexed', 'robotstxt-mediaaudit' )
);
$this->stat_box(
$stats['total_scanned'],
__( 'Scanned', 'robotstxt-mediaaudit' ),
sprintf(
/* translators: %d: percentage */
__( '%d%% of index', 'robotstxt-mediaaudit' ),
$scanned_pct
)
);
$this->stat_box(
$stats['total_used'],
__( 'Used', 'robotstxt-mediaaudit' ),
''
);
$this->stat_box(
$stats['total_unused'],
__( 'Unused', 'robotstxt-mediaaudit' ),
''
);
if ( $stats['total_matches'] > 0 ) {
$this->stat_box(
$stats['total_matches'],
__( 'External Matches', 'robotstxt-mediaaudit' ),
''
);
}
echo '</div>';
}
/**
* Outputs a single stat box.
*
* @param int $value Main number.
* @param string $label Short label.
* @param string $sub Optional sub-label.
*
* @return void
*/
private function stat_box( int $value, string $label, string $sub ): void {
echo '<div class="mra-stat-box">';
echo '<strong>' . esc_html( number_format_i18n( $value ) ) . '</strong>';
echo '<span>' . esc_html( $label ) . '</span>';
if ( '' !== $sub ) {
echo '<small>' . esc_html( $sub ) . '</small>';
}
echo '</div>';
}
/**
* Outputs CSV for selected attachment IDs and terminates the request.
*
* @return void
*/
private function handle_export_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_csv_rows( $ids );
$filename = 'media-audit-' . 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' ) );
}
// BOM for Excel UTF-8 compatibility.
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' ),
__( 'MIME Type', 'robotstxt-mediaaudit' ),
__( 'File Size (bytes)', 'robotstxt-mediaaudit' ),
__( 'External Status', 'robotstxt-mediaaudit' ),
__( 'Usage Count', 'robotstxt-mediaaudit' ),
__( 'Used In (post titles)', 'robotstxt-mediaaudit' ),
)
);
foreach ( $rows as $row ) {
fputcsv( $out, $row );
}
fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
exit;
}
/**
* Outputs an external-results CSV for selected attachment IDs and terminates.
*
* One row per attachment × provider pair. Attachments with no results still
* appear with empty provider columns.
*
* @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.
*
* 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).
*
* @return array<int, array<int, string>>
*/
private function build_external_csv_rows( array $ids ): 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",
ARRAY_A
);
} else {
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
$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",
...$ids
),
ARRAY_A
);
}
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
if ( ! $rows ) {
return array();
}
$output = array();
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'] ?? '';
$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;
}
}
}
$domains_str = implode( '; ', $domain_parts );
$output[] = array(
is_numeric( $aid_raw ) ? (string) (int) $aid_raw : '',
is_string( $r['file_name'] ?? null ) ? (string) $r['file_name'] : '',
is_string( $r['file_url'] ?? null ) ? (string) $r['file_url'] : '',
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,
);
}
return $output;
}
/**
* Queues selected attachments for external scanning and redirects.
*
* @return void
*/
private function handle_run_external_scan(): 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();
if ( ! empty( $ids ) ) {
ExternalScanner::queue_attachments( $ids );
} else {
ExternalScanner::schedule();
}
wp_safe_redirect(
add_query_arg(
array(
'page' => 'robotstxt-mediaaudit',
'mra_notice' => 'scan_scheduled',
),
admin_url( 'admin.php' )
)
);
exit;
}
/**
* Purges external scan data for selected attachments and redirects.
*
* @return void
*/
private function handle_purge_external_data(): 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();
if ( ! empty( $ids ) ) {
ExternalScanner::purge( $ids );
}
wp_safe_redirect(
add_query_arg(
array(
'page' => 'robotstxt-mediaaudit',
'mra_notice' => 'purge_done',
),
admin_url( 'admin.php' )
)
);
exit;
}
/**
* Collects and sanitises attachment IDs from the current bulk action POST data.
*
* @return array<int, int>
*/
private function collect_attachment_ids(): array {
$ids = array();
// phpcs:disable WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended -- nonce verified by all callers.
if ( isset( $_REQUEST['attachment_id'] ) && is_array( $_REQUEST['attachment_id'] ) ) {
foreach ( $_REQUEST['attachment_id'] as $raw ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$id = is_numeric( $raw ) ? (int) $raw : 0;
if ( $id > 0 ) {
$ids[] = $id;
}
}
}
// phpcs:enable WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended
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.
*
* @return string Action name, or '' if none.
*/
private function get_current_bulk_action(): string {
// phpcs:disable WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended
$action = isset( $_REQUEST['action'] ) && is_string( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : '';
if ( '-1' === $action ) {
$action = isset( $_REQUEST['action2'] ) && is_string( $_REQUEST['action2'] ) ? sanitize_key( wp_unslash( $_REQUEST['action2'] ) ) : '';
}
// phpcs:enable WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended
return $action;
}
/**
* 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}
*/
private static function get_dashboard_stats(): array {
global $wpdb;
// phpcs:disable WordPress.DB.DirectDatabaseQuery
$total_indexed = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_index" );
$total_scanned = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_index WHERE internal_scanned_at IS NOT NULL" );
$total_used = (int) $wpdb->get_var( "SELECT COUNT(DISTINCT attachment_id) FROM {$wpdb->prefix}mra_media_usage" );
$total_matches = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_index WHERE external_status = 'matches'" );
// phpcs:enable WordPress.DB.DirectDatabaseQuery
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,
'pending_index' => AttachmentIndexer::get_pending_count(),
);
}
}