robotstxt-mediaaudit/includes/Admin/ToolsPage.php
2026-06-08 18:01:39 +00:00

796 lines
27 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
/**
* Tools & Status admin page.
*
* @package MediaRightsAudit\Admin
*/
namespace MediaRightsAudit\Admin;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
use MediaRightsAudit\Admin\Settings;
use MediaRightsAudit\Core\Database;
use MediaRightsAudit\Core\Queue\Scheduler;
use MediaRightsAudit\External\ExternalScanner;
use MediaRightsAudit\Internal\AttachmentIndexer;
use MediaRightsAudit\Internal\UsageScanner;
/**
* Renders the Tools & Status admin page.
*
* Provides a live system-health overview and one-click bulk operations for
* managing internal and external scan data. All state-changing operations
* use POST + redirect (PRG) to prevent double-submission.
*/
class ToolsPage {
/**
* Nonce action for all tool operations.
*/
const NONCE_ACTION = 'mra_tools_op';
/**
* Nonce field name.
*/
const NONCE_FIELD = 'mra_tools_nonce';
/**
* Nonce action for the AJAX batch runner.
*/
const NONCE_BATCH = 'mra_run_batch';
/**
* Admin page hook suffix, used to scope asset enqueuing.
*
* @var string
*/
private string $hook_suffix = '';
/**
* Stores the hook suffix and registers the load-* hook for pre-output processing.
*
* @param string $suffix Hook suffix returned by 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' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
}
/**
* Enqueues CSS and JS assets for the Tools page.
*
* @param string $hook Current admin page hook suffix.
*
* @return void
*/
public function enqueue_assets( string $hook ): void {
if ( $hook !== $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',
'mraTools',
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( self::NONCE_BATCH ),
'i18n' => array(
'done' => __( 'Done.', 'robotstxt-mediaaudit' ),
'stopped' => __( 'Stopped.', 'robotstxt-mediaaudit' ),
'error' => __( 'An error occurred.', 'robotstxt-mediaaudit' ),
),
)
);
}
/**
* AJAX handler for the batch runner. Processes one batch and returns progress data.
*
* Expected POST fields: type (index|usage|external), nonce.
* Returns JSON: {done: int, total: int, remaining: int}.
*
* @return void
*/
public function ajax_run_batch(): void {
check_ajax_referer( self::NONCE_BATCH, 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'robotstxt-mediaaudit' ) ), 403 );
}
$type = sanitize_key( wp_unslash( isset( $_POST['type'] ) && is_string( $_POST['type'] ) ? $_POST['type'] : '' ) );
$remaining = 0;
$done = 0;
$total = 0;
switch ( $type ) {
case 'index':
AttachmentIndexer::index_batch();
$remaining = AttachmentIndexer::get_pending_count();
$done = AttachmentIndexer::get_indexed_count();
$total = $done + $remaining;
break;
case 'usage':
UsageScanner::scan_batch();
$remaining = UsageScanner::get_pending_count();
$total = AttachmentIndexer::get_indexed_count();
$done = max( 0, $total - $remaining );
break;
case 'external':
ExternalScanner::queue_all_pending();
$opts_arr = Settings::get_effective_settings();
$bs_setting = $opts_arr['external_batch_size'] ?? null;
$ajax_bs = is_numeric( $bs_setting ) ? max( 1, (int) $bs_setting ) : 10;
ExternalScanner::scan_batch( $ajax_bs );
$counts = ExternalScanner::get_status_counts();
$remaining = $counts['queued'] ?? 0;
$done = ( $counts['scanned'] ?? 0 ) + ( $counts['matches'] ?? 0 ) + ( $counts['error'] ?? 0 );
$total = $done + $remaining;
break;
default:
wp_send_json_error( array( 'message' => __( 'Invalid batch type.', 'robotstxt-mediaaudit' ) ), 400 );
}
wp_send_json_success(
array(
'done' => $done,
'total' => $total,
'remaining' => $remaining,
)
);
}
/**
* Fires before page HTML is output; processes POST operations and redirects.
*
* @return void
*/
public function handle_load(): void {
if ( ! isset( $_POST['mra_tools_op'] ) ) {
return;
}
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Insufficient permissions.', 'robotstxt-mediaaudit' ) );
}
check_admin_referer( self::NONCE_ACTION, self::NONCE_FIELD );
$op = sanitize_key( wp_unslash( is_string( $_POST['mra_tools_op'] ) ? $_POST['mra_tools_op'] : '' ) );
$result = $this->dispatch_op( $op );
wp_safe_redirect(
add_query_arg(
array(
'page' => 'robotstxt-mediaaudit-tools',
'mra_notice' => $result['notice'],
'mra_count' => $result['count'],
'mra_op' => $op,
),
admin_url( 'admin.php' )
)
);
exit;
}
/**
* Routes an operation key to the appropriate handler and returns a result array.
*
* @param string $op Operation key from the POST body.
*
* @return array{notice: string, count: int}
*/
private function dispatch_op( string $op ): array {
$count = 0;
switch ( $op ) {
case 'schedule_internal':
AttachmentIndexer::schedule();
UsageScanner::schedule();
break;
case 'schedule_external':
ExternalScanner::schedule();
break;
case 'requeue_errors':
$count = ExternalScanner::requeue_errors();
break;
case 'sync_providers':
$count = ExternalScanner::sync_providers();
break;
case 'rescan_usage':
$count = UsageScanner::reset_all();
UsageScanner::schedule();
break;
case 'reindex_all':
$count = AttachmentIndexer::reset_all();
AttachmentIndexer::schedule();
break;
case 'purge_external':
$count = ExternalScanner::purge_all();
break;
case 'full_reset':
Database::truncate_all();
AttachmentIndexer::schedule();
break;
default:
return array(
'notice' => 'error',
'count' => 0,
);
}
return array(
'notice' => 'success',
'count' => $count,
);
}
/**
* Renders the Tools & Status 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' ) );
}
$status = $this->get_system_status();
$notice = $this->get_notice_data();
?>
<div class="wrap">
<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
<?php $this->render_notice( $notice ); ?>
<h2><?php esc_html_e( 'System Status', 'robotstxt-mediaaudit' ); ?></h2>
<?php $this->render_status( $status ); ?>
<h2 class="mra-ops-heading"><?php esc_html_e( 'Operations', 'robotstxt-mediaaudit' ); ?></h2>
<?php $this->render_operations(); ?>
<h2 class="mra-ops-heading"><?php esc_html_e( 'Direct scan runner', 'robotstxt-mediaaudit' ); ?></h2>
<p class="description">
<?php esc_html_e( 'Run scan batches directly from your browser without relying on background jobs. Useful when WP-Cron is disabled or Action Scheduler is not running. Keep this tab open while processing.', 'robotstxt-mediaaudit' ); ?>
</p>
<?php $this->render_runner(); ?>
</div>
<?php
}
// -------------------------------------------------------------------------
// Status data
// -------------------------------------------------------------------------
/**
* Collects all status data for the status table.
*
* @return array{db_version_ok: bool, stored_version: string, as_available: bool, cron_disabled: bool, total_images: int, indexed: int, pending_index: int, pending_usage: int, external: array<string, int>, jobs: array{index: bool, usage: bool, external: bool}}
*/
private function get_system_status(): array {
global $wpdb;
$raw_ver = get_option( 'robotstxt_mediaaudit_db_version', '' );
$stored_ver = is_string( $raw_ver ) ? $raw_ver : '';
$as_ok = Scheduler::is_available();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$total_images = (int) $wpdb->get_var(
"SELECT COUNT(ID) FROM {$wpdb->posts}
WHERE post_type = 'attachment'
AND post_mime_type LIKE 'image/%'
AND post_status != 'trash'"
);
$indexed = AttachmentIndexer::get_indexed_count();
$pending_idx = AttachmentIndexer::get_pending_count();
$pending_use = UsageScanner::get_pending_count();
$ext_counts = ExternalScanner::get_status_counts();
$fallback_ver = '' !== $stored_ver ? $stored_ver : __( 'not installed', 'robotstxt-mediaaudit' );
return array(
'db_version_ok' => ROBOTSTXT_MEDIAAUDIT_DB_VERSION === $stored_ver,
'stored_version' => $fallback_ver,
'as_available' => $as_ok,
'cron_disabled' => defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON,
'total_images' => $total_images,
'indexed' => $indexed,
'pending_index' => $pending_idx,
'pending_usage' => $pending_use,
'external' => $ext_counts,
'jobs' => array(
'index' => $as_ok && Scheduler::has_pending( AttachmentIndexer::AS_HOOK ),
'usage' => $as_ok && Scheduler::has_pending( UsageScanner::AS_HOOK ),
'external' => $as_ok && Scheduler::has_pending( ExternalScanner::AS_HOOK ),
),
);
}
/**
* Reads PRG redirect query parameters and returns structured notice data.
*
* @return array{type: string, op: string, count: int}|null
*/
private function get_notice_data(): ?array {
// phpcs:disable WordPress.Security.NonceVerification.Recommended
if ( ! isset( $_GET['mra_notice'] ) ) {
return null;
}
$type = sanitize_key( wp_unslash( is_string( $_GET['mra_notice'] ) ? $_GET['mra_notice'] : '' ) );
$op2 = sanitize_key( wp_unslash( isset( $_GET['mra_op'] ) && is_string( $_GET['mra_op'] ) ? $_GET['mra_op'] : '' ) );
$count = isset( $_GET['mra_count'] ) && is_numeric( $_GET['mra_count'] ) ? (int) $_GET['mra_count'] : 0;
return array(
'type' => $type,
'op' => $op2,
'count' => abs( $count ),
);
// phpcs:enable WordPress.Security.NonceVerification.Recommended
}
// -------------------------------------------------------------------------
// Rendering helpers
// -------------------------------------------------------------------------
/**
* Renders a WP admin notice for a completed operation.
*
* @param array{type: string, op: string, count: int}|null $notice Notice data.
*
* @return void
*/
private function render_notice( ?array $notice ): void {
if ( null === $notice || 'success' !== $notice['type'] ) {
return;
}
$messages = array(
'schedule_internal' => __( 'Internal scan scheduled.', 'robotstxt-mediaaudit' ),
'schedule_external' => __( 'External scan scheduled.', 'robotstxt-mediaaudit' ),
'requeue_errors' => __( 'Failed scans re-queued. Background scan scheduled.', 'robotstxt-mediaaudit' ),
'sync_providers' => __( 'Provider registry synced. New scans scheduled.', 'robotstxt-mediaaudit' ),
'rescan_usage' => __( 'Usage data cleared. Background scan scheduled.', 'robotstxt-mediaaudit' ),
'reindex_all' => __( 'Index cleared. Fresh indexing scheduled.', 'robotstxt-mediaaudit' ),
'purge_external' => __( 'External scan data purged.', 'robotstxt-mediaaudit' ),
'full_reset' => __( 'All data reset. Fresh indexing scheduled.', 'robotstxt-mediaaudit' ),
);
$msg = $messages[ $notice['op'] ] ?? __( 'Operation completed.', 'robotstxt-mediaaudit' );
?>
<div class="notice notice-success is-dismissible">
<p><?php echo esc_html( $msg ); ?></p>
</div>
<?php
}
/**
* Renders the system status table.
*
* @param array{db_version_ok: bool, stored_version: string, as_available: bool, cron_disabled: bool, total_images: int, indexed: int, pending_index: int, pending_usage: int, external: array<string, int>, jobs: array{index: bool, usage: bool, external: bool}} $status Status data.
*
* @return void
*/
private function render_status( array $status ): void {
$db_ok = $status['db_version_ok'];
$as_ok = $status['as_available'];
$cron = $status['cron_disabled'];
$tot = $status['total_images'];
$idx = $status['indexed'];
$pi = $status['pending_index'];
$pu = $status['pending_usage'];
$ext = $status['external'];
$ep = $ext['pending'] ?? 0;
$eq = $ext['queued'] ?? 0;
$es = $ext['scanned'] ?? 0;
$em = $ext['matches'] ?? 0;
$ee = $ext['error'] ?? 0;
$jobs = $status['jobs'];
$ji = $jobs['index'];
$ju = $jobs['usage'];
$je = $jobs['external'];
$needs_work = $pi > 0 || $pu > 0 || $ep > 0 || $eq > 0;
$any_job = $ji || $ju || $je;
?>
<table class="widefat striped mra-status-table">
<tbody>
<tr>
<td class="mra-status-dot-col"><?php echo $this->dot( $db_ok ? 'ok' : 'error' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></td>
<th scope="row"><?php esc_html_e( 'Database schema', 'robotstxt-mediaaudit' ); ?></th>
<td>
<?php
if ( $db_ok ) {
printf(
/* translators: %s: version number */
esc_html__( 'v%s up to date', 'robotstxt-mediaaudit' ),
esc_html( ROBOTSTXT_MEDIAAUDIT_DB_VERSION )
);
} else {
printf(
/* translators: 1: stored version, 2: expected version */
esc_html__( 'Installed: %1$s — Required: %2$s', 'robotstxt-mediaaudit' ),
esc_html( $status['stored_version'] ),
esc_html( ROBOTSTXT_MEDIAAUDIT_DB_VERSION )
);
}
?>
</td>
<td class="mra-status-hint">
<?php if ( ! $db_ok ) : ?>
<?php esc_html_e( 'Deactivate and reactivate the plugin to apply pending migrations.', 'robotstxt-mediaaudit' ); ?>
<?php endif; ?>
</td>
</tr>
<tr>
<td class="mra-status-dot-col"><?php echo $this->dot( $as_ok ? 'ok' : 'error' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></td>
<th scope="row"><?php esc_html_e( 'Action Scheduler', 'robotstxt-mediaaudit' ); ?></th>
<td>
<?php if ( $as_ok ) : ?>
<?php esc_html_e( 'Available', 'robotstxt-mediaaudit' ); ?>
<?php else : ?>
<?php esc_html_e( 'Not available', 'robotstxt-mediaaudit' ); ?>
<?php endif; ?>
</td>
<td class="mra-status-hint">
<?php if ( ! $as_ok ) : ?>
<?php esc_html_e( 'Install and activate the Action Scheduler plugin. Background scanning will not work without it.', 'robotstxt-mediaaudit' ); ?>
<?php endif; ?>
</td>
</tr>
<tr>
<td class="mra-status-dot-col"><?php echo $this->dot( $cron ? 'warn' : 'ok' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></td>
<th scope="row"><?php esc_html_e( 'WP-Cron', 'robotstxt-mediaaudit' ); ?></th>
<td>
<?php if ( $cron ) : ?>
<?php esc_html_e( 'Disabled (DISABLE_WP_CRON)', 'robotstxt-mediaaudit' ); ?>
<?php else : ?>
<?php esc_html_e( 'Enabled', 'robotstxt-mediaaudit' ); ?>
<?php endif; ?>
</td>
<td class="mra-status-hint">
<?php if ( $cron ) : ?>
<?php esc_html_e( 'Background jobs will not fire automatically. Trigger the cron externally or use WP-CLI:', 'robotstxt-mediaaudit' ); ?>
<code>wp cron event run --due-now</code>
<?php endif; ?>
</td>
</tr>
<tr>
<td class="mra-status-dot-col"><?php echo $this->dot( $pi > 0 ? 'warn' : 'ok' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></td>
<th scope="row"><?php esc_html_e( 'Internal index', 'robotstxt-mediaaudit' ); ?></th>
<td>
<?php
printf(
/* translators: 1: indexed count, 2: total images */
esc_html__( '%1$s / %2$s images indexed', 'robotstxt-mediaaudit' ),
esc_html( number_format_i18n( $idx ) ),
esc_html( number_format_i18n( $tot ) )
);
?>
</td>
<td class="mra-status-hint">
<?php if ( $pi > 0 ) : ?>
<?php
printf(
/* translators: %s: formatted count */
esc_html( _n( '%s image not yet indexed.', '%s images not yet indexed.', $pi, 'robotstxt-mediaaudit' ) ),
esc_html( number_format_i18n( $pi ) )
);
echo ' ';
esc_html_e( 'Schedule an internal scan below.', 'robotstxt-mediaaudit' );
?>
<?php endif; ?>
</td>
</tr>
<tr>
<td class="mra-status-dot-col"><?php echo $this->dot( $pu > 0 ? 'warn' : 'ok' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></td>
<th scope="row"><?php esc_html_e( 'Usage scan', 'robotstxt-mediaaudit' ); ?></th>
<td>
<?php
printf(
/* translators: 1: scanned count, 2: total indexed */
esc_html__( '%1$s / %2$s scanned for usage', 'robotstxt-mediaaudit' ),
esc_html( number_format_i18n( max( 0, $idx - $pu ) ) ),
esc_html( number_format_i18n( $idx ) )
);
?>
</td>
<td class="mra-status-hint">
<?php if ( $pu > 0 ) : ?>
<?php
printf(
/* translators: %s: formatted count */
esc_html( _n( '%s pending.', '%s pending.', $pu, 'robotstxt-mediaaudit' ) ),
esc_html( number_format_i18n( $pu ) )
);
?>
<?php endif; ?>
</td>
</tr>
<tr>
<td class="mra-status-dot-col">
<?php
if ( $ee > 0 ) {
$ext_dot = 'error';
} elseif ( $ep > 0 || $eq > 0 ) {
$ext_dot = 'warn';
} else {
$ext_dot = 'ok';
}
echo $this->dot( $ext_dot ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
?>
</td>
<th scope="row"><?php esc_html_e( 'External scan', 'robotstxt-mediaaudit' ); ?></th>
<td>
<?php
printf(
/* translators: 1: scanned 2: matches 3: queued 4: pending 5: errors */
esc_html__( '%1$s scanned · %2$s matches · %3$s queued · %4$s pending · %5$s errors', 'robotstxt-mediaaudit' ),
esc_html( number_format_i18n( $es ) ),
esc_html( number_format_i18n( $em ) ),
esc_html( number_format_i18n( $eq ) ),
esc_html( number_format_i18n( $ep ) ),
esc_html( number_format_i18n( $ee ) )
);
?>
</td>
<td class="mra-status-hint">
<?php if ( $ee > 0 ) : ?>
<?php esc_html_e( 'Some scans failed. Check that API keys are configured correctly in Settings.', 'robotstxt-mediaaudit' ); ?>
<?php elseif ( $ep > 0 ) : ?>
<?php esc_html_e( 'Schedule an external scan below to process pending items.', 'robotstxt-mediaaudit' ); ?>
<?php endif; ?>
</td>
</tr>
<tr>
<td class="mra-status-dot-col">
<?php
if ( $as_ok && $needs_work && ! $any_job ) {
$jobs_dot = 'warn';
} elseif ( $any_job ) {
$jobs_dot = 'ok';
} else {
$jobs_dot = 'ok';
}
echo $this->dot( $jobs_dot ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
?>
</td>
<th scope="row"><?php esc_html_e( 'Scheduled jobs', 'robotstxt-mediaaudit' ); ?></th>
<td>
<?php if ( ! $as_ok ) : ?>
<em><?php esc_html_e( 'Action Scheduler not available.', 'robotstxt-mediaaudit' ); ?></em>
<?php else : ?>
<span class="mra-job-label"><?php esc_html_e( 'Index:', 'robotstxt-mediaaudit' ); ?> <strong><?php echo $ji ? esc_html__( 'Pending', 'robotstxt-mediaaudit' ) : '&mdash;'; ?></strong></span>
<span class="mra-job-label"><?php esc_html_e( 'Usage:', 'robotstxt-mediaaudit' ); ?> <strong><?php echo $ju ? esc_html__( 'Pending', 'robotstxt-mediaaudit' ) : '&mdash;'; ?></strong></span>
<span class="mra-job-label"><?php esc_html_e( 'External:', 'robotstxt-mediaaudit' ); ?> <strong><?php echo $je ? esc_html__( 'Pending', 'robotstxt-mediaaudit' ) : '&mdash;'; ?></strong></span>
<?php endif; ?>
</td>
<td class="mra-status-hint">
<?php if ( $as_ok && $needs_work && ! $any_job ) : ?>
<?php esc_html_e( 'There is work to do but no background job is scheduled. Use the operations below.', 'robotstxt-mediaaudit' ); ?>
<?php elseif ( $cron && $any_job ) : ?>
<?php esc_html_e( 'WP-Cron is disabled. Jobs will not fire automatically.', 'robotstxt-mediaaudit' ); ?>
<?php endif; ?>
</td>
</tr>
</tbody>
</table>
<?php if ( $cron ) : ?>
<p class="description mra-cli-hint">
<?php esc_html_e( 'WP-CLI commands to process scans manually:', 'robotstxt-mediaaudit' ); ?>
<code>wp mra scan-internal</code>
<code>wp mra scan-external</code>
</p>
<?php endif; ?>
<?php
}
/**
* Renders the operations grid (normal + destructive).
*
* @return void
*/
private function render_operations(): void {
$ops = array(
array(
'op' => 'schedule_internal',
'label' => __( 'Schedule internal scan', 'robotstxt-mediaaudit' ),
'desc' => __( 'Queue a background job to index all unindexed media files and scan their usage across posts, pages, and custom fields. Safe to run at any time — will not re-process completed work.', 'robotstxt-mediaaudit' ),
'class' => 'button button-primary',
'confirm' => '',
),
array(
'op' => 'schedule_external',
'label' => __( 'Schedule external scan', 'robotstxt-mediaaudit' ),
'desc' => __( 'Mark all pending attachments as queued and launch a background reverse-image-search via the configured providers. Requires API keys in Settings.', 'robotstxt-mediaaudit' ),
'class' => 'button button-primary',
'confirm' => '',
),
array(
'op' => 'requeue_errors',
'label' => __( 'Requeue scan errors', 'robotstxt-mediaaudit' ),
'desc' => __( 'Re-queues only the attachments that failed with an error on the last external scan run. Does not affect already-scanned items. Useful to retry after fixing API key issues.', 'robotstxt-mediaaudit' ),
'class' => 'button',
'confirm' => '',
),
array(
'op' => 'sync_providers',
'label' => __( 'Sync provider registry', 'robotstxt-mediaaudit' ),
'desc' => __( 'Creates scan entries for any newly registered external providers. Run this after adding a new provider via the mra/external/providers filter. Already-scanned attachments are not affected.', 'robotstxt-mediaaudit' ),
'class' => 'button',
'confirm' => '',
),
);
$destructive = array(
array(
'op' => 'rescan_usage',
'label' => __( 'Reset usage data', 'robotstxt-mediaaudit' ),
'desc' => __( 'Clears all usage rows and resets the usage-scan flag for every indexed attachment. File metadata in the index is preserved. A background usage scan is scheduled immediately after.', 'robotstxt-mediaaudit' ),
'class' => 'button',
'confirm' => __( 'This will delete all usage data. Continue?', 'robotstxt-mediaaudit' ),
),
array(
'op' => 'reindex_all',
'label' => __( 'Re-index media library', 'robotstxt-mediaaudit' ),
'desc' => __( 'Deletes all index and usage entries, then schedules a full fresh re-index. External scan results are not affected. Useful if many media files have been added or removed.', 'robotstxt-mediaaudit' ),
'class' => 'button',
'confirm' => __( 'This will delete all index and usage data. Continue?', 'robotstxt-mediaaudit' ),
),
array(
'op' => 'purge_external',
'label' => __( 'Purge external data', 'robotstxt-mediaaudit' ),
'desc' => __( 'Deletes all external scan results and resets every attachment\'s external status to pending. Use this before switching API providers or to force a complete re-scan.', 'robotstxt-mediaaudit' ),
'class' => 'button',
'confirm' => __( 'This will delete all external scan results. Continue?', 'robotstxt-mediaaudit' ),
),
array(
'op' => 'full_reset',
'label' => __( 'Reset all data', 'robotstxt-mediaaudit' ),
'desc' => __( 'Truncates all three plugin tables (index, usage, external results) and schedules a fresh indexing run. Use this to start completely from scratch.', 'robotstxt-mediaaudit' ),
'class' => 'button button-link-delete',
'confirm' => __( 'This will permanently delete ALL plugin data and cannot be undone. Are you absolutely sure?', 'robotstxt-mediaaudit' ),
),
);
?>
<div class="mra-ops-grid">
<?php foreach ( $ops as $op ) : ?>
<?php $this->render_op_card( $op ); ?>
<?php endforeach; ?>
</div>
<h3 class="mra-destructive-heading">
<?php esc_html_e( 'Destructive operations', 'robotstxt-mediaaudit' ); ?>
</h3>
<p class="description">
<?php esc_html_e( 'These operations permanently delete plugin data. Intended for forced rescans, troubleshooting, or starting fresh. A confirmation prompt will appear before any action is taken.', 'robotstxt-mediaaudit' ); ?>
</p>
<div class="mra-ops-grid">
<?php foreach ( $destructive as $op ) : ?>
<?php $this->render_op_card( $op ); ?>
<?php endforeach; ?>
</div>
<?php
}
/**
* Renders a single operation card with a self-contained POST form.
*
* @param array{op: string, label: string, desc: string, class: string, confirm: string} $op Operation definition.
*
* @return void
*/
private function render_op_card( array $op ): void {
?>
<div class="mra-op-card">
<h4 class="mra-op-card-title"><?php echo esc_html( $op['label'] ); ?></h4>
<p class="mra-op-card-desc description"><?php echo esc_html( $op['desc'] ); ?></p>
<form method="post">
<?php wp_nonce_field( self::NONCE_ACTION, self::NONCE_FIELD ); ?>
<input type="hidden" name="mra_tools_op" value="<?php echo esc_attr( $op['op'] ); ?>" />
<button
type="submit"
class="<?php echo esc_attr( $op['class'] ); ?>"
<?php if ( ! empty( $op['confirm'] ) ) : ?>
onclick="return confirm('<?php echo esc_js( $op['confirm'] ); ?>');"
<?php endif; ?>
>
<?php echo esc_html( $op['label'] ); ?>
</button>
</form>
</div>
<?php
}
/**
* Renders the direct scan runner cards.
*
* @return void
*/
private function render_runner(): void {
$runners = array(
array(
'type' => 'index',
'label' => __( 'Index media files', 'robotstxt-mediaaudit' ),
'desc' => __( 'Discovers unindexed images and registers them in the plugin index. Run this first.', 'robotstxt-mediaaudit' ),
),
array(
'type' => 'usage',
'label' => __( 'Scan usage', 'robotstxt-mediaaudit' ),
'desc' => __( 'Checks every indexed image for usage across posts, pages, and custom fields.', 'robotstxt-mediaaudit' ),
),
array(
'type' => 'external',
'label' => __( 'External scan', 'robotstxt-mediaaudit' ),
'desc' => __( 'Sends images to external providers for reverse-image search. Requires API keys in Settings.', 'robotstxt-mediaaudit' ),
),
);
?>
<div class="mra-ops-grid">
<?php foreach ( $runners as $runner ) : ?>
<div class="mra-op-card" id="mra-runner-<?php echo esc_attr( $runner['type'] ); ?>">
<h4 class="mra-op-card-title"><?php echo esc_html( $runner['label'] ); ?></h4>
<p class="mra-op-card-desc description"><?php echo esc_html( $runner['desc'] ); ?></p>
<div class="mra-runner-progress" hidden>
<div class="mra-runner-bar">
<div class="mra-runner-bar-fill"></div>
</div>
<span class="mra-runner-label"></span>
</div>
<div class="mra-runner-actions">
<button type="button" class="button button-primary mra-runner-start">
<?php esc_html_e( 'Run now', 'robotstxt-mediaaudit' ); ?>
</button>
<button type="button" class="button mra-runner-stop" hidden>
<?php esc_html_e( 'Stop', 'robotstxt-mediaaudit' ); ?>
</button>
</div>
</div>
<?php endforeach; ?>
</div>
<?php
}
/**
* Returns an escaped HTML status indicator dot span.
*
* @param string $color One of 'ok', 'warn', 'error'.
*
* @return string
*/
private function dot( string $color ): string {
return '<span class="mra-dot mra-dot-' . esc_attr( $color ) . '" aria-hidden="true"></span>';
}
}