v1.0.0
This commit is contained in:
commit
6708bbb67f
43 changed files with 8342 additions and 0 deletions
705
includes/Admin/AuditPage.php
Normal file
705
includes/Admin/AuditPage.php
Normal file
|
|
@ -0,0 +1,705 @@
|
|||
<?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 ( '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' ) . '">×</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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
570
includes/Admin/MediaListTable.php
Normal file
570
includes/Admin/MediaListTable.php
Normal file
|
|
@ -0,0 +1,570 @@
|
|||
<?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' ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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' ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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' );
|
||||
// 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}";
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
340
includes/Admin/Settings.php
Normal file
340
includes/Admin/Settings.php
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
<?php
|
||||
/**
|
||||
* Settings page handler.
|
||||
*
|
||||
* @package MediaRightsAudit\Admin
|
||||
*/
|
||||
|
||||
namespace MediaRightsAudit\Admin;
|
||||
|
||||
/**
|
||||
* Registers and renders the plugin Settings page.
|
||||
*
|
||||
* Settings are stored in a single option array: robotstxt_mediaaudit_settings.
|
||||
*/
|
||||
class Settings {
|
||||
|
||||
/**
|
||||
* WordPress Settings API option group name.
|
||||
*/
|
||||
const OPTION_GROUP = 'robotstxt_mediaaudit';
|
||||
|
||||
/**
|
||||
* Option name in the database.
|
||||
*/
|
||||
const OPTION_NAME = 'robotstxt_mediaaudit_settings';
|
||||
|
||||
/**
|
||||
* Registers all settings, sections, and fields via the WordPress Settings API.
|
||||
*
|
||||
* Hooked to admin_init.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register(): void {
|
||||
register_setting(
|
||||
self::OPTION_GROUP,
|
||||
self::OPTION_NAME,
|
||||
array(
|
||||
'type' => 'array',
|
||||
'sanitize_callback' => array( $this, 'sanitize' ),
|
||||
'default' => array(),
|
||||
)
|
||||
);
|
||||
|
||||
add_settings_section(
|
||||
'mra_general',
|
||||
__( 'General', 'robotstxt-mediaaudit' ),
|
||||
'__return_false',
|
||||
'robotstxt-mediaaudit-settings'
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
'delete_on_uninstall',
|
||||
__( 'Delete data on uninstall', 'robotstxt-mediaaudit' ),
|
||||
array( $this, 'field_delete_on_uninstall' ),
|
||||
'robotstxt-mediaaudit-settings',
|
||||
'mra_general'
|
||||
);
|
||||
|
||||
add_settings_section(
|
||||
'mra_api_credentials',
|
||||
__( 'API Credentials', 'robotstxt-mediaaudit' ),
|
||||
'__return_false',
|
||||
'robotstxt-mediaaudit-settings'
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
'google_vision_api_key',
|
||||
__( 'Google Cloud Vision API Key', 'robotstxt-mediaaudit' ),
|
||||
array( $this, 'field_google_vision_api_key' ),
|
||||
'robotstxt-mediaaudit-settings',
|
||||
'mra_api_credentials'
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
'tineye_api_key',
|
||||
__( 'TinEye API Key', 'robotstxt-mediaaudit' ),
|
||||
array( $this, 'field_tineye_api_key' ),
|
||||
'robotstxt-mediaaudit-settings',
|
||||
'mra_api_credentials'
|
||||
);
|
||||
|
||||
add_settings_section(
|
||||
'mra_external_scanning',
|
||||
__( 'External Scanning', 'robotstxt-mediaaudit' ),
|
||||
'__return_false',
|
||||
'robotstxt-mediaaudit-settings'
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
'external_batch_size',
|
||||
__( 'Batch Size', 'robotstxt-mediaaudit' ),
|
||||
array( $this, 'field_external_batch_size' ),
|
||||
'robotstxt-mediaaudit-settings',
|
||||
'mra_external_scanning'
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
'rate_limit_per_minute',
|
||||
__( 'Rate Limit (requests/min)', 'robotstxt-mediaaudit' ),
|
||||
array( $this, 'field_rate_limit_per_minute' ),
|
||||
'robotstxt-mediaaudit-settings',
|
||||
'mra_external_scanning'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitises the settings array on save.
|
||||
*
|
||||
* @param mixed $input Raw POST input.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function sanitize( $input ): array {
|
||||
$output = array();
|
||||
|
||||
if ( ! is_array( $input ) ) {
|
||||
return $output;
|
||||
}
|
||||
|
||||
$output['delete_on_uninstall'] = ! empty( $input['delete_on_uninstall'] );
|
||||
|
||||
$key_val = $input['google_vision_api_key'] ?? null;
|
||||
$output['google_vision_api_key'] = is_string( $key_val ) ? sanitize_text_field( $key_val ) : '';
|
||||
|
||||
$tineye_val = $input['tineye_api_key'] ?? null;
|
||||
$output['tineye_api_key'] = is_string( $tineye_val ) ? sanitize_text_field( $tineye_val ) : '';
|
||||
|
||||
$batch_val = $input['external_batch_size'] ?? null;
|
||||
$batch = is_numeric( $batch_val ) ? (int) $batch_val : 10;
|
||||
$output['external_batch_size'] = max( 1, min( 100, $batch ) );
|
||||
|
||||
$rl_val = $input['rate_limit_per_minute'] ?? null;
|
||||
$rl = is_numeric( $rl_val ) ? (int) $rl_val : 10;
|
||||
$output['rate_limit_per_minute'] = max( 1, min( 60, $rl ) );
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the "Delete data on uninstall" checkbox field.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function field_delete_on_uninstall(): void {
|
||||
$options = (array) get_option( self::OPTION_NAME, array() );
|
||||
$checked = ! empty( $options['delete_on_uninstall'] );
|
||||
?>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="<?php echo esc_attr( self::OPTION_NAME ); ?>[delete_on_uninstall]"
|
||||
value="1"
|
||||
<?php checked( $checked ); ?>
|
||||
/>
|
||||
<?php esc_html_e( 'Remove all plugin data (tables and options) when the plugin is uninstalled.', 'robotstxt-mediaaudit' ); ?>
|
||||
</label>
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'By default, data is preserved after uninstall. Enable this only if you want a clean removal.', 'robotstxt-mediaaudit' ); ?>
|
||||
</p>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the Google Cloud Vision API key field.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function field_google_vision_api_key(): void {
|
||||
$options = (array) get_option( self::OPTION_NAME, array() );
|
||||
$val = $options['google_vision_api_key'] ?? '';
|
||||
$value = is_string( $val ) ? $val : '';
|
||||
?>
|
||||
<input
|
||||
type="text"
|
||||
id="google_vision_api_key"
|
||||
name="<?php echo esc_attr( self::OPTION_NAME ); ?>[google_vision_api_key]"
|
||||
value="<?php echo esc_attr( $value ); ?>"
|
||||
class="regular-text"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'Required for Google Cloud Vision Web Detection. Obtain from the Google Cloud Console.', 'robotstxt-mediaaudit' ); ?>
|
||||
—
|
||||
<a href="https://cloud.google.com/vision/pricing" target="_blank" rel="noopener noreferrer">
|
||||
<?php esc_html_e( 'View pricing', 'robotstxt-mediaaudit' ); ?>
|
||||
</a>
|
||||
</p>
|
||||
<table class="mra-pricing-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e( 'Monthly requests', 'robotstxt-mediaaudit' ); ?></th>
|
||||
<th><?php esc_html_e( 'Price per 1,000', 'robotstxt-mediaaudit' ); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><?php esc_html_e( 'First 1,000', 'robotstxt-mediaaudit' ); ?></td>
|
||||
<td><?php esc_html_e( 'Free', 'robotstxt-mediaaudit' ); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>1,001 – 5,000,000</td>
|
||||
<td>$1.50</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>5,000,001+</td>
|
||||
<td>$0.60</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the TinEye API key field.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function field_tineye_api_key(): void {
|
||||
$options = (array) get_option( self::OPTION_NAME, array() );
|
||||
$val = $options['tineye_api_key'] ?? '';
|
||||
$value = is_string( $val ) ? $val : '';
|
||||
?>
|
||||
<input
|
||||
type="text"
|
||||
id="tineye_api_key"
|
||||
name="<?php echo esc_attr( self::OPTION_NAME ); ?>[tineye_api_key]"
|
||||
value="<?php echo esc_attr( $value ); ?>"
|
||||
class="regular-text"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'Required for TinEye reverse image search. Obtain from your TinEye Commercial account.', 'robotstxt-mediaaudit' ); ?>
|
||||
—
|
||||
<a href="https://services.tineye.com/TinEyeAPI" target="_blank" rel="noopener noreferrer">
|
||||
<?php esc_html_e( 'View pricing', 'robotstxt-mediaaudit' ); ?>
|
||||
</a>
|
||||
</p>
|
||||
<table class="mra-pricing-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e( 'Bundle', 'robotstxt-mediaaudit' ); ?></th>
|
||||
<th><?php esc_html_e( 'Searches', 'robotstxt-mediaaudit' ); ?></th>
|
||||
<th><?php esc_html_e( 'Price', 'robotstxt-mediaaudit' ); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><?php esc_html_e( 'Starter', 'robotstxt-mediaaudit' ); ?></td>
|
||||
<td>500</td>
|
||||
<td>$50</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><?php esc_html_e( 'Standard', 'robotstxt-mediaaudit' ); ?></td>
|
||||
<td>5,000</td>
|
||||
<td>$200</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><?php esc_html_e( 'Professional', 'robotstxt-mediaaudit' ); ?></td>
|
||||
<td>25,000</td>
|
||||
<td>$600</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the external batch size field.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function field_external_batch_size(): void {
|
||||
$options = (array) get_option( self::OPTION_NAME, array() );
|
||||
$raw = $options['external_batch_size'] ?? 10;
|
||||
$value = is_numeric( $raw ) ? (int) $raw : 10;
|
||||
?>
|
||||
<input
|
||||
type="number"
|
||||
id="external_batch_size"
|
||||
name="<?php echo esc_attr( self::OPTION_NAME ); ?>[external_batch_size]"
|
||||
value="<?php echo esc_attr( (string) $value ); ?>"
|
||||
min="1"
|
||||
max="100"
|
||||
class="small-text"
|
||||
/>
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'Number of images to scan per scheduled batch (1–100). Default: 10.', 'robotstxt-mediaaudit' ); ?>
|
||||
</p>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the per-minute rate limit field.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function field_rate_limit_per_minute(): void {
|
||||
$options = (array) get_option( self::OPTION_NAME, array() );
|
||||
$raw = $options['rate_limit_per_minute'] ?? 10;
|
||||
$value = is_numeric( $raw ) ? (int) $raw : 10;
|
||||
?>
|
||||
<input
|
||||
type="number"
|
||||
id="rate_limit_per_minute"
|
||||
name="<?php echo esc_attr( self::OPTION_NAME ); ?>[rate_limit_per_minute]"
|
||||
value="<?php echo esc_attr( (string) $value ); ?>"
|
||||
min="1"
|
||||
max="60"
|
||||
class="small-text"
|
||||
/>
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'Maximum API requests per minute per provider (1–60). Default: 10.', 'robotstxt-mediaaudit' ); ?>
|
||||
</p>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the Settings admin page.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render(): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_die( esc_html__( 'You do not have permission to access this page.', 'robotstxt-mediaaudit' ) );
|
||||
}
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
|
||||
<form method="post" action="options.php">
|
||||
<?php
|
||||
settings_fields( self::OPTION_GROUP );
|
||||
do_settings_sections( 'robotstxt-mediaaudit-settings' );
|
||||
submit_button();
|
||||
?>
|
||||
</form>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue