v1.2.0
This commit is contained in:
parent
6708bbb67f
commit
650e69349f
21 changed files with 2919 additions and 268 deletions
|
|
@ -65,6 +65,8 @@ class AuditPage {
|
|||
|
||||
if ( 'export_csv' === $action ) {
|
||||
$this->handle_export_csv();
|
||||
} elseif ( 'export_external_csv' === $action ) {
|
||||
$this->handle_export_external_csv();
|
||||
} elseif ( 'run_external_scan' === $action ) {
|
||||
$this->handle_run_external_scan();
|
||||
} elseif ( 'purge_external_data' === $action ) {
|
||||
|
|
@ -498,6 +500,142 @@ class AuditPage {
|
|||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outputs an external-results CSV for selected attachment IDs and terminates.
|
||||
*
|
||||
* One row per attachment × provider pair. Attachments with no results still
|
||||
* appear with empty provider columns.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function handle_export_external_csv(): void {
|
||||
check_admin_referer( 'bulk-mra-attachments' );
|
||||
|
||||
if ( ! current_user_can( 'edit_posts' ) ) {
|
||||
wp_die( esc_html__( 'Insufficient permissions.', 'robotstxt-mediaaudit' ) );
|
||||
}
|
||||
|
||||
$ids = $this->collect_attachment_ids();
|
||||
$rows = $this->build_external_csv_rows( $ids );
|
||||
|
||||
$filename = 'media-audit-external-' . gmdate( 'Y-m-d' ) . '.csv';
|
||||
|
||||
header( 'Content-Type: text/csv; charset=utf-8' );
|
||||
header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
|
||||
header( 'Pragma: no-cache' );
|
||||
|
||||
$out = fopen( 'php://output', 'w' );
|
||||
if ( false === $out ) {
|
||||
wp_die( esc_html__( 'Could not open output stream.', 'robotstxt-mediaaudit' ) );
|
||||
}
|
||||
|
||||
fwrite( $out, "\xEF\xBB\xBF" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
|
||||
|
||||
fputcsv(
|
||||
$out,
|
||||
array(
|
||||
__( 'Attachment ID', 'robotstxt-mediaaudit' ),
|
||||
__( 'Filename', 'robotstxt-mediaaudit' ),
|
||||
__( 'File URL', 'robotstxt-mediaaudit' ),
|
||||
__( 'External Status', 'robotstxt-mediaaudit' ),
|
||||
__( 'Last Scanned', 'robotstxt-mediaaudit' ),
|
||||
__( 'Provider', 'robotstxt-mediaaudit' ),
|
||||
__( 'Match Count', 'robotstxt-mediaaudit' ),
|
||||
__( 'Top Domains', 'robotstxt-mediaaudit' ),
|
||||
)
|
||||
);
|
||||
|
||||
foreach ( $rows as $row ) {
|
||||
fputcsv( $out, $row );
|
||||
}
|
||||
|
||||
fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds external-results CSV rows for the given attachment IDs.
|
||||
*
|
||||
* Each row represents one attachment × provider combination. Attachments with
|
||||
* no provider results are included with empty provider columns. If $ids is
|
||||
* empty, all indexed attachments are exported.
|
||||
*
|
||||
* @param array<int, int> $ids Attachment IDs to export (empty = all).
|
||||
*
|
||||
* @return array<int, array<int, string>>
|
||||
*/
|
||||
private function build_external_csv_rows( array $ids ): array {
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
|
||||
|
||||
if ( empty( $ids ) ) {
|
||||
$rows = $wpdb->get_results(
|
||||
"SELECT i.attachment_id, i.file_name, i.file_url, i.external_status,
|
||||
i.external_scanned_at, er.provider, er.match_count, er.top_domains
|
||||
FROM {$wpdb->prefix}mra_media_index i
|
||||
LEFT JOIN {$wpdb->prefix}mra_external_results er ON er.attachment_id = i.attachment_id
|
||||
ORDER BY i.attachment_id ASC, er.provider ASC",
|
||||
ARRAY_A
|
||||
);
|
||||
} else {
|
||||
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT i.attachment_id, i.file_name, i.file_url, i.external_status,
|
||||
i.external_scanned_at, er.provider, er.match_count, er.top_domains
|
||||
FROM {$wpdb->prefix}mra_media_index i
|
||||
LEFT JOIN {$wpdb->prefix}mra_external_results er ON er.attachment_id = i.attachment_id
|
||||
WHERE i.attachment_id IN ({$placeholders})
|
||||
ORDER BY i.attachment_id ASC, er.provider ASC",
|
||||
...$ids
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
|
||||
|
||||
if ( ! $rows ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$output = array();
|
||||
|
||||
foreach ( $rows as $r ) {
|
||||
$aid_raw = $r['attachment_id'] ?? '';
|
||||
$scanned_raw = $r['external_scanned_at'] ?? '';
|
||||
$domains_raw = $r['top_domains'] ?? '';
|
||||
$mc_raw = $r['match_count'] ?? '';
|
||||
|
||||
$domains_decoded = is_string( $domains_raw ) && '' !== $domains_raw
|
||||
? json_decode( $domains_raw, true )
|
||||
: array();
|
||||
$domain_parts = array();
|
||||
if ( is_array( $domains_decoded ) ) {
|
||||
foreach ( $domains_decoded as $d ) {
|
||||
if ( is_string( $d ) ) {
|
||||
$domain_parts[] = $d;
|
||||
}
|
||||
}
|
||||
}
|
||||
$domains_str = implode( '; ', $domain_parts );
|
||||
|
||||
$output[] = array(
|
||||
is_numeric( $aid_raw ) ? (string) (int) $aid_raw : '',
|
||||
is_string( $r['file_name'] ?? null ) ? (string) $r['file_name'] : '',
|
||||
is_string( $r['file_url'] ?? null ) ? (string) $r['file_url'] : '',
|
||||
is_string( $r['external_status'] ?? null ) ? (string) $r['external_status'] : '',
|
||||
is_string( $scanned_raw ) ? $scanned_raw : '',
|
||||
is_string( $r['provider'] ?? null ) ? (string) $r['provider'] : '',
|
||||
is_numeric( $mc_raw ) ? (string) (int) $mc_raw : '',
|
||||
$domains_str,
|
||||
);
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues selected attachments for external scanning and redirects.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -47,13 +47,14 @@ class MediaListTable extends \WP_List_Table {
|
|||
*/
|
||||
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' ),
|
||||
'cb' => '<input type="checkbox" />',
|
||||
'thumbnail' => __( 'Thumbnail', 'robotstxt-mediaaudit' ),
|
||||
'filename' => __( 'Filename', 'robotstxt-mediaaudit' ),
|
||||
'attachment_id' => __( 'ID', 'robotstxt-mediaaudit' ),
|
||||
'usage_count' => __( 'Usage Count', 'robotstxt-mediaaudit' ),
|
||||
'usages' => __( 'Usages', 'robotstxt-mediaaudit' ),
|
||||
'external_status' => __( 'External Status', 'robotstxt-mediaaudit' ),
|
||||
'external_scanned_at' => __( 'Last Scanned', 'robotstxt-mediaaudit' ),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -64,8 +65,9 @@ class MediaListTable extends \WP_List_Table {
|
|||
*/
|
||||
protected function get_sortable_columns() {
|
||||
return array(
|
||||
'attachment_id' => array( 'attachment_id', true ),
|
||||
'usage_count' => array( 'usage_count', false ),
|
||||
'attachment_id' => array( 'attachment_id', true ),
|
||||
'usage_count' => array( 'usage_count', false ),
|
||||
'external_scanned_at' => array( 'external_scanned_at', false ),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -79,6 +81,7 @@ class MediaListTable extends \WP_List_Table {
|
|||
'run_external_scan' => __( 'Run External Scan', 'robotstxt-mediaaudit' ),
|
||||
'purge_external_data' => __( 'Purge External Data', 'robotstxt-mediaaudit' ),
|
||||
'export_csv' => __( 'Export CSV', 'robotstxt-mediaaudit' ),
|
||||
'export_external_csv' => __( 'Export External Results CSV', 'robotstxt-mediaaudit' ),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -309,6 +312,47 @@ class MediaListTable extends \WP_List_Table {
|
|||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the external scan date column.
|
||||
*
|
||||
* Shows a human-readable time difference (e.g. "3 days ago") with the full
|
||||
* date/time as a tooltip. Displays "—" when the attachment has never been scanned.
|
||||
*
|
||||
* @param array<string, mixed> $item Row data.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function column_external_scanned_at( $item ) {
|
||||
$raw = $item['external_scanned_at'];
|
||||
|
||||
if ( ! is_string( $raw ) || '' === $raw ) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
$ts = strtotime( $raw );
|
||||
if ( false === $ts ) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
$date_fmt = get_option( 'date_format' );
|
||||
$time_fmt = get_option( 'time_format' );
|
||||
$format = ( is_string( $date_fmt ) ? $date_fmt : 'Y-m-d' )
|
||||
. ' '
|
||||
. ( is_string( $time_fmt ) ? $time_fmt : 'H:i' );
|
||||
$full_val = wp_date( $format, $ts );
|
||||
$full = is_string( $full_val ) ? $full_val : $raw;
|
||||
|
||||
return sprintf(
|
||||
'<abbr title="%s">%s</abbr>',
|
||||
esc_attr( $full ),
|
||||
sprintf(
|
||||
/* translators: %s: human-readable time difference, e.g. "3 days" */
|
||||
esc_html__( '%s ago', 'robotstxt-mediaaudit' ),
|
||||
esc_html( human_time_diff( $ts ) )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders filter controls above the table.
|
||||
*
|
||||
|
|
@ -391,7 +435,7 @@ class MediaListTable extends \WP_List_Table {
|
|||
$offset = ( $paged - 1 ) * $per_page;
|
||||
|
||||
// Sorting.
|
||||
$allowed_orderby = array( 'attachment_id', 'usage_count' );
|
||||
$allowed_orderby = array( 'attachment_id', 'usage_count', 'external_scanned_at' );
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Recommended
|
||||
$orderby_raw = isset( $_REQUEST['orderby'] ) && is_string( $_REQUEST['orderby'] ) ? sanitize_key( wp_unslash( $_REQUEST['orderby'] ) ) : '';
|
||||
$orderby = in_array( $orderby_raw, $allowed_orderby, true ) ? $orderby_raw : 'attachment_id';
|
||||
|
|
@ -438,6 +482,9 @@ class MediaListTable extends \WP_List_Table {
|
|||
|
||||
if ( 'usage_count' === $orderby ) {
|
||||
$order_sql = "(SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_usage mu WHERE mu.attachment_id = i.attachment_id) {$order}";
|
||||
} elseif ( 'external_scanned_at' === $orderby ) {
|
||||
// NULLs always last, regardless of sort direction.
|
||||
$order_sql = "i.external_scanned_at IS NULL ASC, i.external_scanned_at {$order}";
|
||||
} else {
|
||||
$order_sql = "i.attachment_id {$order}";
|
||||
}
|
||||
|
|
|
|||
776
includes/Admin/ToolsPage.php
Normal file
776
includes/Admin/ToolsPage.php
Normal file
|
|
@ -0,0 +1,776 @@
|
|||
<?php
|
||||
/**
|
||||
* Tools & Status admin page.
|
||||
*
|
||||
* @package MediaRightsAudit\Admin
|
||||
*/
|
||||
|
||||
namespace MediaRightsAudit\Admin;
|
||||
|
||||
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();
|
||||
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();
|
||||
?>
|
||||
<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' ),
|
||||
'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' ) : '—'; ?></strong></span>
|
||||
<span class="mra-job-label"><?php esc_html_e( 'Usage:', 'robotstxt-mediaaudit' ); ?> <strong><?php echo $ju ? esc_html__( 'Pending', 'robotstxt-mediaaudit' ) : '—'; ?></strong></span>
|
||||
<span class="mra-job-label"><?php esc_html_e( 'External:', 'robotstxt-mediaaudit' ); ?> <strong><?php echo $je ? esc_html__( 'Pending', 'robotstxt-mediaaudit' ) : '—'; ?></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' => '',
|
||||
),
|
||||
);
|
||||
|
||||
$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>';
|
||||
}
|
||||
}
|
||||
|
|
@ -57,6 +57,23 @@ class Database {
|
|||
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_media_index`" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncates all plugin tables, removing all data while preserving the table structure.
|
||||
*
|
||||
* Used by the Tools page "Reset all data" operation.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function truncate_all(): void {
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery
|
||||
$wpdb->query( "TRUNCATE TABLE `{$wpdb->prefix}mra_external_results`" );
|
||||
$wpdb->query( "TRUNCATE TABLE `{$wpdb->prefix}mra_media_usage`" );
|
||||
$wpdb->query( "TRUNCATE TABLE `{$wpdb->prefix}mra_media_index`" );
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Migrations
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ namespace MediaRightsAudit\Core;
|
|||
|
||||
use MediaRightsAudit\Admin\AuditPage;
|
||||
use MediaRightsAudit\Admin\Settings;
|
||||
use MediaRightsAudit\Admin\ToolsPage;
|
||||
use MediaRightsAudit\CLI\Command;
|
||||
use MediaRightsAudit\External\AbstractProvider;
|
||||
use MediaRightsAudit\External\ExternalScanner;
|
||||
|
|
@ -38,12 +39,20 @@ class Plugin {
|
|||
*/
|
||||
private AuditPage $audit_page;
|
||||
|
||||
/**
|
||||
* Tools & Status page controller.
|
||||
*
|
||||
* @var ToolsPage
|
||||
*/
|
||||
private ToolsPage $tools_page;
|
||||
|
||||
/**
|
||||
* Initialises dependencies.
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->settings = new Settings();
|
||||
$this->audit_page = new AuditPage();
|
||||
$this->tools_page = new ToolsPage();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -59,6 +68,7 @@ class Plugin {
|
|||
add_action( AttachmentIndexer::AS_HOOK, array( AttachmentIndexer::class, 'process_scheduled_batch' ) );
|
||||
add_action( UsageScanner::AS_HOOK, array( UsageScanner::class, 'process_scheduled_batch' ) );
|
||||
add_action( ExternalScanner::AS_HOOK, array( ExternalScanner::class, 'process_scheduled_batch' ) );
|
||||
add_action( 'wp_ajax_mra_run_batch', array( $this->tools_page, 'ajax_run_batch' ) );
|
||||
add_filter( 'mra/external/providers', array( $this, 'register_providers' ) );
|
||||
add_filter( 'wp_privacy_personal_data_exporters', array( DataExporter::class, 'register' ) );
|
||||
add_filter( 'wp_privacy_personal_data_erasers', array( DataEraser::class, 'register' ) );
|
||||
|
|
@ -107,6 +117,19 @@ class Plugin {
|
|||
array( $this->audit_page, 'render' )
|
||||
);
|
||||
|
||||
$tools_suffix = add_submenu_page(
|
||||
'robotstxt-mediaaudit',
|
||||
__( 'Tools', 'robotstxt-mediaaudit' ),
|
||||
__( 'Tools', 'robotstxt-mediaaudit' ),
|
||||
'manage_options',
|
||||
'robotstxt-mediaaudit-tools',
|
||||
array( $this->tools_page, 'render' )
|
||||
);
|
||||
|
||||
if ( is_string( $tools_suffix ) ) {
|
||||
$this->tools_page->set_hook_suffix( $tools_suffix );
|
||||
}
|
||||
|
||||
add_submenu_page(
|
||||
'robotstxt-mediaaudit',
|
||||
__( 'Settings', 'robotstxt-mediaaudit' ),
|
||||
|
|
|
|||
89
includes/External/ExternalScanner.php
vendored
89
includes/External/ExternalScanner.php
vendored
|
|
@ -184,6 +184,39 @@ class ExternalScanner {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-queues all attachments that previously failed with external_status = 'error'.
|
||||
*
|
||||
* Does not touch pending, queued, scanned, or matched attachments.
|
||||
* Schedules a background scan batch if any items were re-queued and
|
||||
* no batch is already pending.
|
||||
*
|
||||
* @return int Number of attachments re-queued.
|
||||
*/
|
||||
public static function requeue_errors(): int {
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery
|
||||
$count = (int) $wpdb->get_var(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_index WHERE external_status = 'error'"
|
||||
);
|
||||
|
||||
if ( $count > 0 ) {
|
||||
$wpdb->query(
|
||||
"UPDATE {$wpdb->prefix}mra_media_index
|
||||
SET external_status = 'queued', external_scanned_at = NULL
|
||||
WHERE external_status = 'error'"
|
||||
);
|
||||
|
||||
if ( ! Scheduler::has_pending( self::AS_HOOK ) ) {
|
||||
Scheduler::schedule_single( self::AS_HOOK );
|
||||
}
|
||||
}
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes external scan results and resets status to pending for given IDs.
|
||||
*
|
||||
|
|
@ -221,6 +254,62 @@ class ExternalScanner {
|
|||
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all external scan results and resets every attachment's external_status to pending.
|
||||
*
|
||||
* @return int Number of result rows deleted.
|
||||
*/
|
||||
public static function purge_all(): int {
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery
|
||||
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}mra_external_results" );
|
||||
$wpdb->query( "DELETE FROM {$wpdb->prefix}mra_external_results" );
|
||||
$wpdb->query(
|
||||
"UPDATE {$wpdb->prefix}mra_media_index
|
||||
SET external_status = 'pending', external_scanned_at = NULL"
|
||||
);
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a count of attachments grouped by external_status.
|
||||
*
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public static function get_status_counts(): array {
|
||||
global $wpdb;
|
||||
|
||||
$rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
"SELECT external_status, COUNT(*) AS cnt
|
||||
FROM {$wpdb->prefix}mra_media_index
|
||||
GROUP BY external_status",
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
$counts = array(
|
||||
'pending' => 0,
|
||||
'queued' => 0,
|
||||
'scanned' => 0,
|
||||
'matches' => 0,
|
||||
'error' => 0,
|
||||
);
|
||||
|
||||
if ( is_array( $rows ) ) {
|
||||
foreach ( $rows as $row ) {
|
||||
$s = is_array( $row ) && is_string( $row['external_status'] ) ? $row['external_status'] : '';
|
||||
if ( isset( $counts[ $s ] ) ) {
|
||||
$cnt = isset( $row['cnt'] ) && is_numeric( $row['cnt'] ) ? (int) $row['cnt'] : 0;
|
||||
$counts[ $s ] = $cnt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -172,4 +172,21 @@ class AttachmentIndexer {
|
|||
Scheduler::schedule_single( self::AS_HOOK );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all rows from mra_media_index and mra_media_usage, forcing a full re-index on the next run.
|
||||
*
|
||||
* @return int Number of index rows removed.
|
||||
*/
|
||||
public static function reset_all(): int {
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery
|
||||
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_index" );
|
||||
$wpdb->query( "DELETE FROM {$wpdb->prefix}mra_media_usage" );
|
||||
$wpdb->query( "DELETE FROM {$wpdb->prefix}mra_media_index" );
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery
|
||||
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -461,4 +461,26 @@ class UsageScanner {
|
|||
Scheduler::schedule_single( self::AS_HOOK );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets usage scan state for all indexed attachments.
|
||||
*
|
||||
* Clears internal_scanned_at so every attachment is re-queued for scanning, and
|
||||
* deletes all mra_media_usage rows. File metadata in mra_media_index is preserved.
|
||||
*
|
||||
* @return int Number of attachments reset.
|
||||
*/
|
||||
public static function reset_all(): int {
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery
|
||||
$count = (int) $wpdb->get_var(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_index WHERE internal_scanned_at IS NOT NULL"
|
||||
);
|
||||
$wpdb->query( "UPDATE {$wpdb->prefix}mra_media_index SET internal_scanned_at = NULL" );
|
||||
$wpdb->query( "DELETE FROM {$wpdb->prefix}mra_media_usage" );
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery
|
||||
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue