509318396
­­­­­­­­­­­­­­­­­­
<!DOCTYPE html>
<html>
<?php
/**
 * The Sweep screen.
 *
 * @package WP-Sweep
 */

defined( 'ABSPATH' ) || exit;

/**
 * Registers the admin menu and renders the screen every sweep is run from.
 *
 * Until 2.0.0 this was includes/admin.php, a template required at global scope
 * that reached for globals it never declared and repeated the same eleven
 * lines of markup nineteen times. The rows are now generated from
 * WP_Sweep::get_sweeps(), so adding a sweep is one entry in one list.
 */
class WP_Sweep_Admin {

	/**
	 * The menu and page slug.
	 *
	 * Until 2.0.0 the screen was registered with 'wp-sweep/admin.php' -- the
	 * legacy "plugin file as menu slug" form. That put the installation
	 * directory name into the page URL and into the hook suffix handed back to
	 * admin_enqueue_scripts, so both broke for anyone who installed the plugin
	 * under a different directory name.
	 *
	 * @var string
	 */
	const PAGE = 'wp-sweep';

	/**
	 * The id of the region a finished sweep reports into.
	 *
	 * Written once, here, and used twice: on the region itself and in the
	 * aria-controls of every Sweep link the list table draws. The script
	 * follows that association rather than looking for the region in the
	 * markup around the row, because the two are not near each other -- the
	 * region sits above the form, the rows are inside it -- and a script that
	 * navigates the gap breaks the day the gap changes. It did: see
	 * messageContainer() in js/wp-sweep-admin.js.
	 *
	 * @var string
	 */
	const MESSAGE_ID = 'wp-sweep-message';

	/**
	 * The hook suffix WordPress handed back when the menu was registered.
	 *
	 * Recorded rather than assumed. get_plugin_page_hookname() derives the
	 * prefix from $admin_page_hooks, so the suffix is 'toplevel_page_wp-sweep'
	 * on a real admin request but something else anywhere the admin menu has
	 * not been built. Comparing against a hardcoded string means the script
	 * silently fails to load in the cases that do not match, and the screen
	 * renders with dead buttons.
	 *
	 * @var string
	 */
	private static $hook_suffix = '';

	/**
	 * The details a row action asked for, keyed by sweep name.
	 *
	 * @var array
	 */
	private static $details = array();

	/**
	 * Hook registration.
	 *
	 * @return void
	 */
	public static function init() {
		add_action( 'admin_menu', array( __CLASS__, 'add_page' ) );
		add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue' ) );
		add_action( 'wp_ajax_sweep', array( __CLASS__, 'ajax_sweep' ) );
		add_action( 'wp_ajax_sweep_details', array( __CLASS__, 'ajax_sweep_details' ) );
		add_action( 'wp_ajax_wp_sweep_count', array( __CLASS__, 'ajax_sweep_count' ) );
		add_action( 'wp_ajax_wp_sweep_totals', array( __CLASS__, 'ajax_sweep_totals' ) );
		add_filter(
			'plugin_action_links_' . plugin_basename( WP_SWEEP_MAIN_FILE ),
			array( __CLASS__, 'action_links' )
		);
	}

	/**
	 * Add a Sweep link on the Plugins screen row.
	 *
	 * @param string[] $links Existing action links.
	 * @return string[]
	 */
	public static function action_links( $links ) {
		array_unshift(
			$links,
			sprintf(
				'<a href="%s">%s</a>',
				esc_url( add_query_arg( 'page', self::PAGE, admin_url( 'tools.php' ) ) ),
				esc_html__( 'Sweep', 'wp-sweep' )
			)
		);

		return $links;
	}

	/**
	 * The list table, built once the screen is being rendered.
	 *
	 * @return WP_Sweep_List_Table
	 */
	private static function list_table() {
		require_once WP_SWEEP_DIR . 'includes/class-wp-sweep-list-table.php';

		return new WP_Sweep_List_Table();
	}

	/**
	 * Register the menu.
	 *
	 * @return void
	 */
	public static function add_page() {
		// Tools, where this screen lived for its whole released life. Sweeping is
		// maintenance against the installation, which is what core keeps under
		// Tools beside Site Health, Export and Erase Personal Data.
		//
		// This is only possible because there is no settings screen: the one
		// setting became the wp_sweep_limit_details filter. Tools has no submenus,
		// so a second screen would have had to become a tab of this one, and that
		// would have made WP-Sweep the only plugin of the nineteen where a data
		// screen and a settings screen share a page.
		self::$hook_suffix = add_management_page(
			_x( 'Sweep', 'Page title', 'wp-sweep' ),
			_x( 'WP-Sweep', 'Menu title', 'wp-sweep' ),
			WP_Sweep::capability( 'sweep' ),
			self::PAGE,
			array( __CLASS__, 'render_page' )
		);
	}

	/**
	 * The hook suffix WordPress gave the Sweep screen.
	 *
	 * @return string Hook suffix, or an empty string before admin_menu runs.
	 */
	public static function get_hook_suffix() {
		return self::$hook_suffix;
	}

	/**
	 * Load the screen's script, and only on the screen that needs it.
	 *
	 * @param string $hook Hook suffix of the screen being rendered.
	 * @return void
	 */
	public static function enqueue( $hook ) {
		if ( '' === self::$hook_suffix || self::$hook_suffix !== $hook ) {
			return;
		}

		/*
		 * One unminified file, no dependencies. There is no build step in this
		 * plugin, so a hand-minified twin drifts out of sync with the source it
		 * is supposed to mirror -- and under a kilobyte gzipped the saving was
		 * noise.
		 *
		 * The URL comes from WP_SWEEP_URL, which is derived from the main file.
		 * Building it from the literal 'wp-sweep/js/...' meant the script 404ed
		 * for anyone who installed the plugin under a different directory name.
		 */
		wp_enqueue_script( 'wp-sweep-admin', WP_SWEEP_URL . 'js/wp-sweep-admin.js', array(), WP_SWEEP_VERSION, true );

		wp_localize_script(
			'wp-sweep-admin',
			'wpSweepL10n',
			array(
				'textCloseWarning'   => __( 'Sweeping is in progress. If you leave now, the process won\'t be completed.', 'wp-sweep' ),
				'textSweep'          => __( 'Sweep', 'wp-sweep' ),
				'textSweeping'       => __( 'Sweeping...', 'wp-sweep' ),
				'textNa'             => __( 'N/A', 'wp-sweep' ),
				'textNothingToSweep' => __( 'Nothing to sweep', 'wp-sweep' ),
			)
		);
	}

	/**
	 * Render the screen.
	 *
	 * @return void
	 */
	public static function render_page() {
		if ( ! current_user_can( WP_Sweep::capability( 'sweep' ) ) ) {
			wp_die( esc_html__( 'You do not have permission to sweep this site.', 'wp-sweep' ) );
		}

		$sweep = WP_Sweep::get_instance();

		self::handle_request();

		$table = self::list_table();
		$table->prepare_items();

		?>
		<div class="wrap">
			<h1><?php echo esc_html_x( 'Sweep', 'Page title', 'wp-sweep' ); ?></h1>

			<?php
			/*
			 * Core's common.js gathers every notice on the screen and re-inserts
			 * the lot of them directly after this marker, falling back to the
			 * first heading it can find when a screen has not printed one. So the
			 * marker, not the source order, is what decides where the reload
			 * path's message ends up -- and printing no marker was how the
			 * permanent warning below came to sit between the heading and the
			 * region, above a message the script had written underneath it.
			 */
			?>
			<hr class="wp-header-end">

			<?php settings_errors( 'wp_sweep' ); ?>

			<?php
			/*
			 * One region per screen, immediately after the marker, so the two
			 * ways a sweep reports are neighbours: a bulk sweep posts and
			 * reloads, and settings_errors() above has just printed into the
			 * space the marker anchors; a row's Sweep button does not reload and
			 * the script writes in here. Nothing may be printed between the two,
			 * or one screen answers the same question in two places. This region
			 * sat below the warning, the description and the totals table, which
			 * is where it was when the screen was a page of separate forms.
			 *
			 * role="status" is what tells a screen reader anything happened at
			 * all, since nothing reloads on the script's path.
			 */
			?>
			<div class="sweep-message" id="<?php echo esc_attr( self::MESSAGE_ID ); ?>" role="status"></div>

			<?php
			/*
			 * `inline` is core's opt-out from that gathering, and this warning is
			 * the one notice on the screen that has to take it. It reports
			 * nothing and never changes, so hoisting it to the marker only puts
			 * it between the reload path's message and the region -- which is
			 * exactly where it was, and why the two paths still did not agree
			 * once the region had been moved up here.
			 */
			?>
			<div class="notice notice-warning inline">
				<p>
					<?php
					echo wp_kses_post(
						sprintf(
							/* translators: %s is the URL of the WP-DBManager plugin. */
							__( 'Before you do any sweep, please <a href="%s">backup your database</a> first, because any sweep done is irreversible.', 'wp-sweep' ),
							'https://wordpress.org/plugins/wp-dbmanager/'
						)
					);
					?>
				</p>
			</div>

			<p class="description">
				<?php
				echo esc_html(
					sprintf(
						/* translators: %s is the maximum number of sample items. */
						__( 'Details lists a sample of up to %s items. Filter wp_sweep_limit_details to change it.', 'wp-sweep' ),
						number_format_i18n( $sweep->limit_details() )
					)
				);
				?>
			</p>

			<?php if ( WP_Sweep_List_Table::defer_counts() ) : ?>
				<?php
				/*
				 * The counts are fetched by the script once the screen is up,
				 * which is what lets the screen come up at all on a database
				 * where a single count takes half a minute. Without the script
				 * nothing fetches them, so this link -- and only readers
				 * without JavaScript ever see it -- is the real, reloading
				 * path to the same numbers.
				 */
				?>
				<noscript>
					<p>
						<a href="<?php echo esc_url( add_query_arg( 'counts', 'now', self::page_url() ) ); ?>">
							<?php esc_html_e( 'Show the counts now. On a large database this can take a while.', 'wp-sweep' ); ?>
						</a>
					</p>
				</noscript>
			<?php endif; ?>

			<?php self::render_totals(); ?>

			<?php $table->views(); ?>

			<form method="post" action="<?php echo esc_url( self::page_url() ); ?>">
				<?php
				// No wp_nonce_field() here: the list table prints its own, under
				// the same _wpnonce name. See WP_Sweep_List_Table::bulk_nonce_action().
				$table->display();
				?>
			</form>

			<?php self::fire_group_actions(); ?>
		</div>
		<?php
	}

	/**
	 * The screen's own URL, carrying the group currently being shown.
	 *
	 * @return string
	 */
	private static function page_url() {
		$args = array(
			'page'  => self::PAGE,
			'group' => WP_Sweep_List_Table::current_group(),
		);

		// The bulk form posts here, and a reader who asked for the counts with
		// the page is a reader without the script to fetch them afterwards --
		// so the view they chose has to survive the round trip.
		if ( WP_Sweep_List_Table::counts_requested_now() ) {
			$args['counts'] = 'now';
		}

		return add_query_arg( $args, admin_url( 'tools.php' ) );
	}

	/**
	 * Render the running totals each group is measured against.
	 *
	 * The spans carry the class the script writes updated totals back into
	 * after a sweep, so the numbers do not go stale without a reload.
	 *
	 * @return void
	 */
	private static function render_totals() {
		$sweep = WP_Sweep::get_instance();
		$defer = WP_Sweep_List_Table::defer_counts();

		// A widefat table rather than a bullet list. These are six rows of
		// numbers, and as a list they ran together as prose with nothing lining
		// up; in a table the counts sit in a column and can be read down. It uses
		// core's own admin classes, so it needs no stylesheet of its own.
		//
		// When the counts are deferred the table carries the nonce for the one
		// request that fills every total at once. On the row counts the nonce
		// rides on each cell; here twelve cells share one fetch, so it rides on
		// their table.
		printf(
			'<table class="widefat striped sweep-totals"%s>',
			$defer ? ' data-nonce="' . esc_attr( wp_create_nonce( 'wp_sweep_totals' ) ) . '"' : ''
		);
		echo '<thead><tr>';
		printf( '<th scope="col">%s</th>', esc_html__( 'Group', 'wp-sweep' ) );
		printf( '<th scope="col">%s</th>', esc_html__( 'Currently in your database', 'wp-sweep' ) );
		echo '</tr></thead><tbody>';

		foreach ( self::totals() as $label => $types ) {
			$parts = array();

			foreach ( $types as $type => $type_label ) {
				// Bold unconditionally here, unlike the sweep list's Count
				// column. There a zero means "nothing to do about this row" and
				// emphasising it would draw the eye to the one thing not worth
				// looking at; here the number is simply the content of the cell,
				// and a site with none of something is as much a fact as a site
				// with a thousand.
				$parts[] = sprintf(
					'<strong class="sweep-count-type-%1$s%2$s">%3$s</strong> %4$s',
					esc_attr( $type ),
					$defer ? ' sweep-total-pending' : '',
					$defer ? '&hellip;' : esc_html( number_format_i18n( $sweep->total_count( $type ) ) ),
					esc_html( $type_label )
				);
			}

			printf(
				'<tr><th scope="row">%1$s</th><td>%2$s</td></tr>',
				esc_html( $label ),
				wp_kses_post( implode( ', ', $parts ) )
			);
		}

		echo '</tbody></table>';
	}

	/**
	 * The table types each group reports a total for.
	 *
	 * @return array Type label keyed by type, keyed by group label.
	 */
	private static function totals() {
		/*
		 * Labelled with plain nouns, not with get_sweep_groups().
		 *
		 * Those read "Post Sweep", "Comment Sweep" and so on, which is right
		 * above the table where they filter which sweeps are listed. Here they
		 * would be wrong: these are counts of what is in the database right now,
		 * not of anything a sweep would remove. A row saying "Post Sweep 3 Posts"
		 * reads as three posts waiting to be deleted.
		 */
		return array(
			__( 'Posts', 'wp-sweep' )    => array(
				'posts'    => __( 'Posts', 'wp-sweep' ),
				'postmeta' => __( 'Post Meta', 'wp-sweep' ),
			),
			__( 'Comments', 'wp-sweep' ) => array(
				'comments'    => __( 'Comments', 'wp-sweep' ),
				'commentmeta' => __( 'Comment Meta', 'wp-sweep' ),
			),
			__( 'Users', 'wp-sweep' )    => array(
				'users'    => __( 'Users', 'wp-sweep' ),
				'usermeta' => __( 'User Meta', 'wp-sweep' ),
			),
			__( 'Terms', 'wp-sweep' )    => array(
				'terms'              => __( 'Terms', 'wp-sweep' ),
				'termmeta'           => __( 'Term Meta', 'wp-sweep' ),
				'term_taxonomy'      => __( 'Term Taxonomy', 'wp-sweep' ),
				'term_relationships' => __( 'Term Relationships', 'wp-sweep' ),
			),
			__( 'Options', 'wp-sweep' )  => array(
				'options' => __( 'Options', 'wp-sweep' ),
			),
			__( 'Database', 'wp-sweep' ) => array(
				'tables' => __( 'Tables', 'wp-sweep' ),
			),
		);
	}

	/**
	 * Fire the extension points other plugins hang their own rows off.
	 *
	 * @return void
	 */
	private static function fire_group_actions() {
		/**
		 * Fires below the sweep table, where the post sweeps used to end.
		 *
		 * @since 1.0.4
		 */
		do_action( 'wp_sweep_admin_post_sweep' );

		/**
		 * Fires below the sweep table, where the comment sweeps used to end.
		 *
		 * @since 1.0.4
		 */
		do_action( 'wp_sweep_admin_comment_sweep' );

		/**
		 * Fires below the sweep table, where the user sweeps used to end.
		 *
		 * @since 1.0.4
		 */
		do_action( 'wp_sweep_admin_user_sweep' );

		/**
		 * Fires below the sweep table, where the term sweeps used to end.
		 *
		 * @since 1.0.4
		 */
		do_action( 'wp_sweep_admin_term_sweep' );

		/**
		 * Fires below the sweep table, where the option sweeps used to end.
		 *
		 * @since 1.0.4
		 */
		do_action( 'wp_sweep_admin_option_sweep' );

		/**
		 * Fires below the sweep table, where the database sweeps used to end.
		 *
		 * @since 1.0.4
		 */
		do_action( 'wp_sweep_admin_database_sweep' );
	}

	/**
	 * Act on whatever the request asked for, before anything is rendered.
	 *
	 * Every one of these paths works with JavaScript turned off: the row
	 * actions are real nonced links and the bulk action is a real form post.
	 * The script intercepts the links so the screen does not reload nineteen
	 * times, but nothing depends on it being there.
	 *
	 * @return void
	 */
	private static function handle_request() {
		self::$details = array();

		self::handle_bulk_sweep();
		self::handle_single_sweep();
		self::handle_details();
	}

	/**
	 * Sweep everything that was checked.
	 *
	 * @return void
	 */
	private static function handle_bulk_sweep() {
		$table = self::list_table();

		if ( 'sweep' !== $table->current_action() ) {
			return;
		}

		check_admin_referer( $table->bulk_nonce_action() );

		$sweep    = WP_Sweep::get_instance();
		$posted   = isset( $_POST['sweep'] ) ? array_map( 'sanitize_key', (array) wp_unslash( $_POST['sweep'] ) ) : array();
		$messages = array();

		// Filtered from the canonical list rather than taken as given, so the
		// sweeps run in the order they have to and an unknown name is dropped
		// rather than passed on.
		$selected = array_intersect( $sweep->get_sweep_names(), $posted );

		foreach ( $selected as $name ) {
			$message = $sweep->sweep( $name );

			if ( '' !== $message ) {
				$messages[] = $message;
			}
		}

		/*
		 * Two different outcomes, and they used to share one message.
		 *
		 * Ticking nothing and ticking a sweep that had nothing left in it both
		 * ended with an empty $messages and the screen saying "Nothing was
		 * selected", which is simply false in the second case -- and reachable
		 * now that every row carries a checkbox whether it has anything to sweep
		 * or not.
		 */
		if ( empty( $selected ) ) {
			add_settings_error( 'wp_sweep', 'wp_sweep_nothing', __( 'Nothing was selected, so nothing was swept.', 'wp-sweep' ), 'warning' );

			return;
		}

		if ( empty( $messages ) ) {
			add_settings_error( 'wp_sweep', 'wp_sweep_nothing', __( 'There was nothing left to sweep.', 'wp-sweep' ), 'warning' );

			return;
		}

		/*
		 * Joined with wp_sprintf( '%l' ), which builds a localised list -- "a, b
		 * and c" in English, and whatever the locale does instead everywhere
		 * else. Imploding on a space ran them together into "11 Transient Options
		 * Processed 20 Tables Processed", which reads as one broken sentence.
		 *
		 * Escaped here, not by settings_errors(), which prints the message
		 * straight into the page. Every one of these comes back through the
		 * wp_sweep_sweep filter, which is public API and can return anything.
		 */
		add_settings_error( 'wp_sweep', 'wp_sweep_swept', esc_html( wp_sprintf( '%l', $messages ) ), 'success' );
	}

	/**
	 * Run the one sweep a row action asked for.
	 *
	 * The name is validated against the plugin's own list before the referer is
	 * checked, so a crafted value never reaches sweep(). The resulting message
	 * is rendered by render_page(); before 2.0.0 it was assigned and then
	 * silently discarded, which meant this path deleted data and told the user
	 * nothing at all.
	 *
	 * @return void
	 */
	private static function handle_single_sweep() {
		$sweep = WP_Sweep::get_instance();
		$name  = isset( $_GET['sweep'] ) ? sanitize_key( wp_unslash( $_GET['sweep'] ) ) : '';

		if ( '' === $name || ! $sweep->is_sweep_name_valid( $name ) ) {
			return;
		}

		check_admin_referer( 'wp_sweep_' . $name );

		$message = $sweep->sweep( $name );

		if ( '' === $message ) {
			add_settings_error( 'wp_sweep', 'wp_sweep_nothing', __( 'There was nothing left to sweep.', 'wp-sweep' ), 'warning' );

			return;
		}

		// Escaped for the reason given in handle_bulk_sweep().
		add_settings_error( 'wp_sweep', 'wp_sweep_swept', esc_html( $message ), 'success' );
	}

	/**
	 * Collect the details a row action asked for.
	 *
	 * @return void
	 */
	private static function handle_details() {
		$sweep = WP_Sweep::get_instance();
		$name  = isset( $_GET['sweep_details'] ) ? sanitize_key( wp_unslash( $_GET['sweep_details'] ) ) : '';

		if ( '' === $name || ! $sweep->is_sweep_name_valid( $name ) ) {
			return;
		}

		check_admin_referer( 'wp_sweep_details_' . $name );

		self::$details = array( $name => (array) $sweep->details( $name ) );
	}

	/**
	 * The details a row action asked for, keyed by sweep name.
	 *
	 * @return array
	 */
	public static function requested_details() {
		return self::$details;
	}

	/**
	 * Return the details of a sweep over AJAX.
	 *
	 * @return void
	 */
	public static function ajax_sweep_details() {
		$sweep = WP_Sweep::get_instance();

		$name = isset( $_GET['sweep_name'] ) ? sanitize_key( wp_unslash( $_GET['sweep_name'] ) ) : '';

		// Permissions and the name are checked before the referer, so a caller
		// without the capability gets a JSON error rather than the nonce
		// failure screen.
		if ( ! current_user_can( WP_Sweep::capability( 'ajax' ) ) || ! $sweep->is_sweep_name_valid( $name ) ) {
			wp_send_json_error( array( 'error' => __( 'Invalid AJAX request.', 'wp-sweep' ) ) );
		}

		check_admin_referer( 'wp_sweep_details_' . $name );

		wp_send_json_success( $sweep->details( $name ) );
	}

	/**
	 * Run a sweep over AJAX and hand back the numbers the screen has to update.
	 *
	 * @return void
	 */
	public static function ajax_sweep() {
		$sweep = WP_Sweep::get_instance();

		$name = isset( $_GET['sweep_name'] ) ? sanitize_key( wp_unslash( $_GET['sweep_name'] ) ) : '';
		$type = isset( $_GET['sweep_type'] ) ? sanitize_key( wp_unslash( $_GET['sweep_type'] ) ) : '';

		if (
			! current_user_can( WP_Sweep::capability( 'ajax' ) )
			|| ! $sweep->is_sweep_name_valid( $name )
			|| ! $sweep->is_sweep_type_valid( $type )
		) {
			wp_send_json_error( array( 'error' => __( 'Invalid AJAX request.', 'wp-sweep' ) ) );
		}

		check_admin_referer( 'wp_sweep_' . $name );

		$message     = $sweep->sweep( $name );
		$count       = (int) $sweep->count( $name );
		$total_count = (int) $sweep->total_count( $type );

		wp_send_json_success(
			array(
				'sweep'      => $message,
				'count'      => $count,
				'total'      => $total_count,
				'percentage' => $sweep->format_percentage( $count, $total_count ),
				'stats'      => self::related_totals( $type ),
			)
		);
	}

	/**
	 * Count one sweep over AJAX, for a screen that rendered without counts.
	 *
	 * The screen defers its counts so it can render before the queries run;
	 * this is where the script gets each number afterwards. The shape matches
	 * the fields ajax_sweep() responds with, so the script fills a cell the
	 * same way whichever request produced the value.
	 *
	 * @return void
	 */
	public static function ajax_sweep_count() {
		$sweep = WP_Sweep::get_instance();

		$name = isset( $_GET['sweep_name'] ) ? sanitize_key( wp_unslash( $_GET['sweep_name'] ) ) : '';
		$type = isset( $_GET['sweep_type'] ) ? sanitize_key( wp_unslash( $_GET['sweep_type'] ) ) : '';

		if (
			! current_user_can( WP_Sweep::capability( 'ajax' ) )
			|| ! $sweep->is_sweep_name_valid( $name )
			|| ! $sweep->is_sweep_type_valid( $type )
		) {
			wp_send_json_error( array( 'error' => __( 'Invalid AJAX request.', 'wp-sweep' ) ) );
		}

		check_admin_referer( 'wp_sweep_count_' . $name );

		$count       = (int) $sweep->count( $name );
		$total_count = (int) $sweep->total_count( $type );

		wp_send_json_success(
			array(
				'count'      => $count,
				'total'      => $total_count,
				'percentage' => $sweep->format_percentage( $count, $total_count ),
			)
		);
	}

	/**
	 * Count every table's rows over AJAX, for the running totals table.
	 *
	 * One request for all twelve, rather than a total on each count response:
	 * the totals are COUNT(*) scans of whole tables, and answering them once
	 * is the point of deferring them.
	 *
	 * @return void
	 */
	public static function ajax_sweep_totals() {
		$sweep = WP_Sweep::get_instance();

		if ( ! current_user_can( WP_Sweep::capability( 'ajax' ) ) ) {
			wp_send_json_error( array( 'error' => __( 'Invalid AJAX request.', 'wp-sweep' ) ) );
		}

		check_admin_referer( 'wp_sweep_totals' );

		$totals = array();

		foreach ( $sweep->get_sweep_types() as $type ) {
			$totals[ $type ] = (int) $sweep->total_count( $type );
		}

		wp_send_json_success( array( 'stats' => $totals ) );
	}

	/**
	 * The totals that move when a sweep of a given type runs.
	 *
	 * Deleting a post takes its meta with it, so the postmeta total is stale
	 * the moment a post sweep finishes even though nothing swept postmeta.
	 *
	 * @param string $type Sweep type.
	 * @return array Row counts keyed by type.
	 */
	private static function related_totals( $type ) {
		$related = array(
			'posts'              => array( 'posts', 'postmeta' ),
			'postmeta'           => array( 'posts', 'postmeta' ),
			'comments'           => array( 'comments', 'commentmeta' ),
			'commentmeta'        => array( 'comments', 'commentmeta' ),
			'users'              => array( 'users', 'usermeta' ),
			'usermeta'           => array( 'users', 'usermeta' ),
			'term_relationships' => array( 'term_relationships', 'term_taxonomy', 'terms', 'termmeta' ),
			'term_taxonomy'      => array( 'term_relationships', 'term_taxonomy', 'terms', 'termmeta' ),
			'terms'              => array( 'term_relationships', 'term_taxonomy', 'terms', 'termmeta' ),
			'termmeta'           => array( 'term_relationships', 'term_taxonomy', 'terms', 'termmeta' ),
			'options'            => array( 'options' ),
			'tables'             => array( 'tables' ),
		);

		if ( ! isset( $related[ $type ] ) ) {
			return array();
		}

		$sweep  = WP_Sweep::get_instance();
		$totals = array();

		foreach ( $related[ $type ] as $related_type ) {
			$totals[ $related_type ] = (int) $sweep->total_count( $related_type );
		}

		return $totals;
	}
}
