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(); ExternalScanner::scan_batch(); $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 '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(); ?>

render_notice( $notice ); ?>

render_status( $status ); ?>

render_operations(); ?>

render_runner(); ?>
, 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' ), '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' ); ?>

, 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; ?>
dot( $db_ok ? 'ok' : 'error' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
dot( $as_ok ? 'ok' : 'error' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
dot( $cron ? 'warn' : 'ok' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> wp cron event run --due-now
dot( $pi > 0 ? 'warn' : 'ok' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> 0 ) : ?>
dot( $pu > 0 ? 'warn' : 'ok' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> 0 ) : ?>
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 ?> 0 ) : ?> 0 ) : ?>
dot( $jobs_dot ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>

wp mra scan-internal wp mra scan-external

'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' => '', ), ); $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' ), ), ); ?>
render_op_card( $op ); ?>

render_op_card( $op ); ?>

'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' ), ), ); ?>

'; } }