Your IP : 216.73.216.196
<?php
/**
* Plugin bootstrap and the sweep engine.
*
* @package WP-Sweep
*/
defined( 'ABSPATH' ) || exit;
/**
* Counts, describes and deletes the rubbish WordPress leaves behind.
*
* Everything the plugin knows how to clean up is described once, in
* get_sweeps(), and three parallel switch statements act on it: count() says
* how much there is, details() shows a sample, and sweep() deletes it. The
* REST routes, the WP-CLI command and the admin screen are all thin callers of
* those three.
*/
class WP_Sweep {
/**
* The capability required to count, inspect or run a sweep.
*
* Not manage_options: since 1.1.3 this has been activate_plugins, because
* update_plugins returns false when DISALLOW_FILE_MODS is set and that took
* the screen away from the very administrators who needed it. The settings
* screen is a separate surface and takes manage_options, per the standard.
*
* @var string
*/
const CAPABILITY = 'activate_plugins';
/**
* How many items a details list shows unless wp_sweep_limit_details says otherwise.
*
* The whole sample is held in memory and written into the page, which is why
* there is a limit at all.
*
* @var int
*/
const DEFAULT_LIMIT_DETAILS = 500;
/**
* The LIKE pattern matching WordPress's oEmbed response caches.
*
* Two things were wrong with the `%_oembed_%` it replaces, and escaping the
* underscores only fixes one of them.
*
* In a LIKE pattern `_` matches any single character, and
* `$wpdb->prepare()` escapes quotes and backslashes but has no opinion about
* wildcards -- so the pattern really meant "contains oembed with at least
* one character either side", and `xoembedy` matched. The transient sweep
* next door had this right (`%\_transient\_%`), which is what makes it an
* oversight rather than a decision.
*
* Escaping alone still leaves it a *contains* match, though, and
* `plugin_oembed_settings` contains `_oembed_` quite literally. Core writes
* these caches as `_oembed_{hash}` and `_oembed_time_{hash}` -- always that
* prefix -- so the pattern is anchored at the start. A key belonging to
* somebody else that merely mentions the word is no longer this sweep's
* business, which it never should have been.
*
* @var string
*/
const OEMBED_LIKE = '\_oembed\_%';
/**
* Static instance.
*
* @var WP_Sweep|null
*/
private static $instance;
/**
* Register hooks.
*
* There is no load_plugin_textdomain() call. Since WordPress 6.7, loading a
* text domain this early triggers _doing_it_wrong; core loads translations
* for plugins hosted on WordPress.org by itself, at the right moment.
*/
private function __construct() {
require_once WP_SWEEP_DIR . 'includes/class-wp-sweep-api.php';
new WP_Sweep_API();
// Must be registered at file-load time, which is when this runs.
add_action( 'plugins_loaded', array( $this, 'add_hooks' ) );
}
/**
* Get the instance, creating it on first call.
*
* @return WP_Sweep
*/
public static function get_instance() {
if ( ! isset( self::$instance ) ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Add the plugin's hooks.
*
* @return void
*/
public function add_hooks() {
self::register_command();
/*
* Not gated on is_admin(). The admin-ajax.php endpoint is an admin
* request, so the gate would hold in production -- but it also makes
* whether the plugin answers AJAX at all depend on when is_admin()
* happens to be decided, which is exactly the kind of thing that works
* everywhere except the one place it does not. The hooks the class
* registers simply never fire on a front end request.
*/
require_once WP_SWEEP_DIR . 'includes/class-wp-sweep-admin.php';
WP_Sweep_Admin::init();
}
/**
* Register the WP-CLI command.
*
* The class file is required here rather than at plugin load because it
* extends WP_CLI_Command, which only exists when WP-CLI is the one running
* WordPress. Requiring it unconditionally is a fatal error on every web
* request.
*
* @return void
*/
public static function register_command() {
if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {
return;
}
require_once WP_SWEEP_DIR . 'includes/class-wp-sweep-command.php';
WP_CLI::add_command( 'sweep', 'WP_Sweep_Command' );
}
/**
* The capability required for a given context.
*
* @param string $context What the capability is being checked for.
* @return string The required capability.
*/
public static function capability( $context = 'sweep' ) {
/**
* Filters the capability required to reach a WP-Sweep screen.
*
* The same filter gates the AJAX and REST surfaces, through their own
* contexts.
*
* @since 2.0.0
*
* @param string $capability The required capability.
* @param string $context What the capability is being checked for.
*/
return (string) apply_filters( 'wp_sweep_capability', self::CAPABILITY, $context );
}
/**
* Run one of this plugin's own statements against the database.
*
* Every database call the plugin makes goes through here, which is the one
* place to look when you want to know what it touches.
*
* WP-Sweep exists to find rows that no WordPress API can see -- meta whose
* object was deleted, terms attached to nothing, relationships pointing at
* posts that are gone. There is no cached equivalent of any of it, and a
* cached count would be wrong the moment anything else on the site wrote,
* which is why phpcs.xml excuses WordPress.DB.DirectDatabaseQuery inside
* includes/ across the whole collection.
*
* Values are bound by the caller, which is where the literal SQL lives, so
* $sql arrives here already through $wpdb->prepare() whenever it carries
* anything a filter or a request could have influenced. That half is not
* excused anywhere: interpolating into SQL stays an error.
*
* @param string $method One of get_var, get_col, get_results or query.
* @param string $sql The statement to run.
* @return mixed Whatever $wpdb's method returns.
*/
private function db( $method, $sql ) {
global $wpdb;
return $wpdb->$method( $sql );
}
/**
* Every sweep this plugin knows how to run.
*
* The single list the REST API, the WP-CLI command, the AJAX handlers and
* the admin screen all work from. Keeping one copy is what stops a name
* being added to the switch statements but missed in an allow list, where
* it is silently unavailable rather than an error.
*
* The order matters: posts are deleted before the sweeps that hunt for the
* meta that deleting them just orphaned.
*
* @return array Sweep descriptors keyed by sweep name, each carrying a
* translated label, the table type its percentage is measured
* against, and the group it is shown under.
*/
public function get_sweeps() {
return array(
'revisions' => array(
'label' => __( 'Revisions', 'wp-sweep' ),
'description' => __( 'Older copies of a post kept every time you saved it. The post itself is untouched.', 'wp-sweep' ),
'type' => 'posts',
'group' => 'posts',
),
'auto_drafts' => array(
'label' => __( 'Auto Drafts', 'wp-sweep' ),
'description' => __( 'Blank posts WordPress created when you opened the editor and never published.', 'wp-sweep' ),
'type' => 'posts',
'group' => 'posts',
),
'deleted_posts' => array(
'label' => __( 'Deleted Posts', 'wp-sweep' ),
'description' => __( 'Posts and pages already in the Trash. Emptying the Trash by hand does the same thing.', 'wp-sweep' ),
'type' => 'posts',
'group' => 'posts',
),
'unapproved_comments' => array(
'label' => __( 'Unapproved Comments', 'wp-sweep' ),
'description' => __( 'Comments still waiting in the moderation queue. Read them before you sweep.', 'wp-sweep' ),
'type' => 'comments',
'group' => 'comments',
),
'spam_comments' => array(
'label' => __( 'Spammed Comments', 'wp-sweep' ),
'description' => __( 'Comments already marked as spam.', 'wp-sweep' ),
'type' => 'comments',
'group' => 'comments',
),
'deleted_comments' => array(
'label' => __( 'Deleted Comments', 'wp-sweep' ),
'description' => __( 'Comments already in the Trash.', 'wp-sweep' ),
'type' => 'comments',
'group' => 'comments',
),
'transient_options' => array(
'label' => __( 'Transient Options', 'wp-sweep' ),
'description' => __( 'Expired cached values. WordPress and your plugins rebuild whatever they still need.', 'wp-sweep' ),
'type' => 'options',
'group' => 'options',
),
'orphan_postmeta' => array(
'label' => __( 'Orphaned Post Meta', 'wp-sweep' ),
'description' => __( 'Custom fields whose post no longer exists.', 'wp-sweep' ),
'type' => 'postmeta',
'group' => 'posts',
),
'orphan_commentmeta' => array(
'label' => __( 'Orphaned Comment Meta', 'wp-sweep' ),
'description' => __( 'Comment metadata whose comment no longer exists.', 'wp-sweep' ),
'type' => 'commentmeta',
'group' => 'comments',
),
'orphan_usermeta' => array(
'label' => __( 'Orphaned User Meta', 'wp-sweep' ),
'description' => __( 'User metadata whose user no longer exists.', 'wp-sweep' ),
'type' => 'usermeta',
'group' => 'users',
),
'orphan_termmeta' => array(
'label' => __( 'Orphaned Term Meta', 'wp-sweep' ),
'description' => __( 'Term metadata whose category or tag no longer exists.', 'wp-sweep' ),
'type' => 'termmeta',
'group' => 'terms',
),
'orphan_term_relationships' => array(
'label' => __( 'Orphaned Term Relationships', 'wp-sweep' ),
'description' => __( 'Links between a category or tag and a post that is gone.', 'wp-sweep' ),
'type' => 'term_relationships',
'group' => 'terms',
),
'unused_terms' => array(
'label' => __( 'Unused Terms', 'wp-sweep' ),
'description' => __( 'Terms used only by drafts look unused, so check your drafts first.', 'wp-sweep' ),
'type' => 'terms',
'group' => 'terms',
),
'duplicated_postmeta' => array(
'label' => __( 'Duplicated Post Meta', 'wp-sweep' ),
'description' => __( 'Custom fields stored more than once on the same post. One copy is kept.', 'wp-sweep' ),
'type' => 'postmeta',
'group' => 'posts',
),
'duplicated_commentmeta' => array(
'label' => __( 'Duplicated Comment Meta', 'wp-sweep' ),
'description' => __( 'Comment metadata stored more than once on the same comment. One copy is kept.', 'wp-sweep' ),
'type' => 'commentmeta',
'group' => 'comments',
),
'duplicated_usermeta' => array(
'label' => __( 'Duplicated User Meta', 'wp-sweep' ),
'description' => __( 'User metadata stored more than once on the same user. One copy is kept.', 'wp-sweep' ),
'type' => 'usermeta',
'group' => 'users',
),
'duplicated_termmeta' => array(
'label' => __( 'Duplicated Term Meta', 'wp-sweep' ),
'description' => __( 'Term metadata stored more than once on the same term. One copy is kept.', 'wp-sweep' ),
'type' => 'termmeta',
'group' => 'terms',
),
'optimize_database' => array(
'label' => __( 'Optimize Tables', 'wp-sweep' ),
'description' => __( 'Reclaims space MySQL is still holding after deletions.', 'wp-sweep' ),
'type' => 'tables',
'group' => 'database',
),
'oembed_postmeta' => array(
'label' => __( 'oEmbed Caches In Post Meta', 'wp-sweep' ),
'description' => __( 'Cached copies of embedded tweets, videos and the like. They are fetched again when needed.', 'wp-sweep' ),
'type' => 'postmeta',
'group' => 'posts',
),
);
}
/**
* The names of every sweep, in the order they must run.
*
* @return array Sweep names.
*/
public function get_sweep_names() {
return array_keys( $this->get_sweeps() );
}
/**
* Whether a sweep name came from the list above.
*
* @param string $name Sweep name.
* @return bool Whether the name is one this plugin implements.
*/
public function is_sweep_name_valid( $name ) {
return in_array( $name, $this->get_sweep_names(), true );
}
/**
* The table shorthands total_count() understands.
*
* @return array Sweep types.
*/
public function get_sweep_types() {
return array(
'posts',
'postmeta',
'comments',
'commentmeta',
'users',
'usermeta',
'term_relationships',
'term_taxonomy',
'terms',
'termmeta',
'options',
'tables',
);
}
/**
* Whether a sweep type came from the list above.
*
* @param string $type Sweep type.
* @return bool Whether the type is one this plugin counts.
*/
public function is_sweep_type_valid( $type ) {
return in_array( $type, $this->get_sweep_types(), true );
}
/**
* The groups the admin screen files the sweeps under.
*
* @return array Translated group labels keyed by group name.
*/
public function get_sweep_groups() {
return array(
'posts' => __( 'Post Sweep', 'wp-sweep' ),
'comments' => __( 'Comment Sweep', 'wp-sweep' ),
'users' => __( 'User Sweep', 'wp-sweep' ),
'terms' => __( 'Term Sweep', 'wp-sweep' ),
'options' => __( 'Option Sweep', 'wp-sweep' ),
'database' => __( 'Database Sweep', 'wp-sweep' ),
);
}
/**
* The dashicon shown beside a group.
*
* A dashicon rather than an SVG of our own: core already loads the font in
* wp-admin, so this ships no asset at all, inherits the admin colour scheme
* without a stylesheet, and cannot fall foul of §11's ban on raster images.
* All six exist in the WordPress 6.8 floor.
*
* Kept apart from get_sweep_groups() rather than folded into it. That method
* returns group => label and two tests read it with array_keys(); widening
* the value to an array would break every `as $group => $label` loop for the
* sake of one more field.
*
* The icon is decoration. Every caller prints it `aria-hidden` beside the
* label, never instead of it -- a screen reader gets the words, and so does
* anyone whose browser has not loaded the font.
*
* @param string $group Group name.
* @return string Dashicon class suffix, or an empty string for an unknown group.
*/
public function get_sweep_group_icon( $group ) {
$icons = array(
'posts' => 'admin-post',
'comments' => 'admin-comments',
'users' => 'admin-users',
'terms' => 'tag',
'options' => 'admin-settings',
'database' => 'database',
);
return isset( $icons[ $group ] ) ? $icons[ $group ] : '';
}
/**
* How many items a details list may show.
*
* @return int Maximum number of sample items.
*/
public function limit_details() {
/**
* Filters how many items a details list may show.
*
* A filter and not a setting on purpose: one field does not earn a
* screen, an option row and a sanitiser. See DEFAULT_LIMIT_DETAILS.
*
* @since 2.0.0
*
* @param int $limit Maximum number of sample items.
*/
return max( 1, (int) apply_filters( 'wp_sweep_limit_details', self::DEFAULT_LIMIT_DETAILS ) );
}
/**
* Count the total number of rows in the table a sweep is measured against.
*
* @param string $name Sweep type.
* @return int Number of rows in that table.
*/
public function total_count( $name ) {
global $wpdb;
$count = 0;
switch ( $name ) {
case 'posts':
$count = $this->db( 'get_var', "SELECT COUNT(*) FROM $wpdb->posts" );
break;
case 'postmeta':
$count = $this->db( 'get_var', "SELECT COUNT(*) FROM $wpdb->postmeta" );
break;
case 'comments':
$count = $this->db( 'get_var', "SELECT COUNT(*) FROM $wpdb->comments" );
break;
case 'commentmeta':
$count = $this->db( 'get_var', "SELECT COUNT(*) FROM $wpdb->commentmeta" );
break;
case 'users':
$count = $this->db( 'get_var', "SELECT COUNT(*) FROM $wpdb->users" );
break;
case 'usermeta':
$count = $this->db( 'get_var', "SELECT COUNT(*) FROM $wpdb->usermeta" );
break;
case 'term_relationships':
$count = $this->db( 'get_var', "SELECT COUNT(*) FROM $wpdb->term_relationships" );
break;
case 'term_taxonomy':
$count = $this->db( 'get_var', "SELECT COUNT(*) FROM $wpdb->term_taxonomy" );
break;
case 'terms':
$count = $this->db( 'get_var', "SELECT COUNT(*) FROM $wpdb->terms" );
break;
case 'termmeta':
$count = $this->db( 'get_var', "SELECT COUNT(*) FROM $wpdb->termmeta" );
break;
case 'options':
$count = $this->db( 'get_var', "SELECT COUNT(*) FROM $wpdb->options" );
break;
case 'tables':
$count = count( $this->tables() );
break;
}
/**
* Filters the total number of rows a sweep's percentage is measured against.
*
* @since 1.0.4
*
* @param int $count The row count.
* @param string $name The sweep type.
*/
return apply_filters( 'wp_sweep_total_count', $count, $name );
}
/**
* Count the number of items belonging to a sweep.
*
* @param string $name Sweep name.
* @return int Number of items the sweep would remove.
*/
public function count( $name ) {
global $wpdb;
$count = 0;
switch ( $name ) {
case 'revisions':
$count = $this->db( 'get_var', $wpdb->prepare( "SELECT COUNT(ID) FROM $wpdb->posts WHERE post_type = %s", 'revision' ) );
break;
case 'auto_drafts':
$count = $this->db( 'get_var', $wpdb->prepare( "SELECT COUNT(ID) FROM $wpdb->posts WHERE post_status = %s", 'auto-draft' ) );
break;
case 'deleted_posts':
$count = $this->db( 'get_var', $wpdb->prepare( "SELECT COUNT(ID) FROM $wpdb->posts WHERE post_status = %s", 'trash' ) );
break;
case 'unapproved_comments':
$count = $this->db( 'get_var', $wpdb->prepare( "SELECT COUNT(comment_ID) FROM $wpdb->comments WHERE comment_approved = %s", '0' ) );
break;
case 'spam_comments':
$count = $this->db( 'get_var', $wpdb->prepare( "SELECT COUNT(comment_ID) FROM $wpdb->comments WHERE comment_approved = %s", 'spam' ) );
break;
case 'deleted_comments':
$count = $this->db( 'get_var', $wpdb->prepare( "SELECT COUNT(comment_ID) FROM $wpdb->comments WHERE ( comment_approved = %s OR comment_approved = %s )", 'trash', 'post-trashed' ) );
break;
case 'transient_options':
$count = $this->db( 'get_var', $wpdb->prepare( "SELECT COUNT(option_id) FROM $wpdb->options WHERE option_name LIKE %s", '%\_transient\_%' ) );
break;
case 'orphan_postmeta':
case 'orphan_commentmeta':
case 'orphan_usermeta':
case 'orphan_termmeta':
$count = $this->count_protected_meta( $this->orphan_meta_counts( $name ), $name );
break;
case 'orphan_term_relationships':
$excluded = $this->excluded_taxonomies_for_sql();
$count = $this->db(
'get_var',
$wpdb->prepare(
"SELECT COUNT(tr.object_id) FROM $wpdb->term_relationships AS tr INNER JOIN $wpdb->term_taxonomy AS tt ON tr.term_taxonomy_id = tt.term_taxonomy_id WHERE tt.taxonomy NOT IN (" . implode( ', ', array_fill( 0, count( $excluded ), '%s' ) ) . ") AND tr.object_id NOT IN ( SELECT ID FROM $wpdb->posts )",
$excluded
)
);
break;
case 'unused_terms':
$count = count( $this->unused_terms() );
break;
case 'duplicated_postmeta':
case 'duplicated_commentmeta':
case 'duplicated_usermeta':
case 'duplicated_termmeta':
$count = $this->count_protected_meta( $this->duplicated_meta_counts( $name ), $name );
break;
case 'optimize_database':
$count = count( $this->tables() );
break;
case 'oembed_postmeta':
$count = $this->count_protected_meta( $this->oembed_meta_counts(), $name );
break;
}
/**
* Filters the number of items a sweep would remove.
*
* @since 1.0.4
*
* @param int $count The item count.
* @param string $name The sweep name.
*/
return apply_filters( 'wp_sweep_count', $count, $name );
}
/**
* Return a sample of the items a sweep would remove.
*
* The sample is capped at the details limit and the protected meta keys are
* dropped from it afterwards, so a site that protects a very common key
* sees fewer entries here than count() reports. The list is a preview; the
* count and the sweep itself are exact.
*
* @param string $name Sweep name.
* @return array Details of the items belonging to the sweep.
*/
public function details( $name ) {
global $wpdb;
$details = array();
$limit = $this->limit_details();
switch ( $name ) {
case 'revisions':
$details = $this->db( 'get_col', $wpdb->prepare( "SELECT post_title FROM $wpdb->posts WHERE post_type = %s LIMIT %d", 'revision', $limit ) );
break;
case 'auto_drafts':
$details = $this->db( 'get_col', $wpdb->prepare( "SELECT post_title FROM $wpdb->posts WHERE post_status = %s LIMIT %d", 'auto-draft', $limit ) );
break;
case 'deleted_posts':
$details = $this->db( 'get_col', $wpdb->prepare( "SELECT post_title FROM $wpdb->posts WHERE post_status = %s LIMIT %d", 'trash', $limit ) );
break;
case 'unapproved_comments':
$details = $this->db( 'get_col', $wpdb->prepare( "SELECT comment_author FROM $wpdb->comments WHERE comment_approved = %s LIMIT %d", '0', $limit ) );
break;
case 'spam_comments':
$details = $this->db( 'get_col', $wpdb->prepare( "SELECT comment_author FROM $wpdb->comments WHERE comment_approved = %s LIMIT %d", 'spam', $limit ) );
break;
case 'deleted_comments':
$details = $this->db( 'get_col', $wpdb->prepare( "SELECT comment_author FROM $wpdb->comments WHERE ( comment_approved = %s OR comment_approved = %s ) LIMIT %d", 'trash', 'post-trashed', $limit ) );
break;
case 'transient_options':
$details = $this->db( 'get_col', $wpdb->prepare( "SELECT option_name FROM $wpdb->options WHERE option_name LIKE %s LIMIT %d", '%\_transient\_%', $limit ) );
break;
case 'orphan_postmeta':
case 'orphan_commentmeta':
case 'orphan_usermeta':
case 'orphan_termmeta':
$details = $this->drop_protected_meta( $this->orphan_meta_keys( $name, $limit ), $name );
break;
case 'orphan_term_relationships':
$excluded = $this->excluded_taxonomies_for_sql();
$details = $this->db(
'get_col',
$wpdb->prepare(
"SELECT tt.taxonomy FROM $wpdb->term_relationships AS tr INNER JOIN $wpdb->term_taxonomy AS tt ON tr.term_taxonomy_id = tt.term_taxonomy_id WHERE tt.taxonomy NOT IN (" . implode( ', ', array_fill( 0, count( $excluded ), '%s' ) ) . ") AND tr.object_id NOT IN ( SELECT ID FROM $wpdb->posts ) LIMIT %d",
array_merge( $excluded, array( $limit ) )
)
);
break;
case 'unused_terms':
$details = array_slice( wp_list_pluck( $this->unused_terms(), 'name' ), 0, $limit );
break;
case 'duplicated_postmeta':
case 'duplicated_commentmeta':
case 'duplicated_usermeta':
case 'duplicated_termmeta':
$details = array_slice( wp_list_pluck( $this->drop_protected_meta( $this->duplicated_meta_counts( $name ), $name ), 'meta_key' ), 0, $limit );
break;
case 'optimize_database':
$details = $this->tables();
break;
case 'oembed_postmeta':
$details = array_slice( wp_list_pluck( $this->drop_protected_meta( $this->oembed_meta_counts(), $name ), 'meta_key' ), 0, $limit );
break;
}
/**
* Filters the sample list of items shown for a sweep.
*
* @since 1.0.3
*
* @param array $details The sample items.
* @param string $name The sweep name.
*/
return apply_filters( 'wp_sweep_details', $details, $name );
}
/**
* Do the sweeping.
*
* @param string $name Sweep name.
* @return string Translated result message, or an empty string when there
* was nothing to remove.
*/
public function sweep( $name ) {
global $wpdb;
$message = '';
switch ( $name ) {
case 'revisions':
$query = $this->db( 'get_col', $wpdb->prepare( "SELECT ID FROM $wpdb->posts WHERE post_type = %s", 'revision' ) );
if ( $query ) {
foreach ( $query as $id ) {
wp_delete_post_revision( (int) $id );
}
// translators: %s is the number of revisions.
$message = sprintf( __( '%s Revisions Processed', 'wp-sweep' ), number_format_i18n( count( $query ) ) );
}
break;
case 'auto_drafts':
$query = $this->db( 'get_col', $wpdb->prepare( "SELECT ID FROM $wpdb->posts WHERE post_status = %s", 'auto-draft' ) );
if ( $query ) {
foreach ( $query as $id ) {
wp_delete_post( (int) $id, true );
}
// translators: %s is the number of auto drafts.
$message = sprintf( __( '%s Auto Drafts Processed', 'wp-sweep' ), number_format_i18n( count( $query ) ) );
}
break;
case 'deleted_posts':
$query = $this->db( 'get_col', $wpdb->prepare( "SELECT ID FROM $wpdb->posts WHERE post_status = %s", 'trash' ) );
if ( $query ) {
foreach ( $query as $id ) {
wp_delete_post( (int) $id, true );
}
// translators: %s is the number of deleted posts.
$message = sprintf( __( '%s Deleted Posts Processed', 'wp-sweep' ), number_format_i18n( count( $query ) ) );
}
break;
case 'unapproved_comments':
$query = $this->db( 'get_col', $wpdb->prepare( "SELECT comment_ID FROM $wpdb->comments WHERE comment_approved = %s", '0' ) );
if ( $query ) {
foreach ( $query as $id ) {
wp_delete_comment( (int) $id, true );
}
// translators: %s is the number of unapproved comments.
$message = sprintf( __( '%s Unapproved Comments Processed', 'wp-sweep' ), number_format_i18n( count( $query ) ) );
}
break;
case 'spam_comments':
$query = $this->db( 'get_col', $wpdb->prepare( "SELECT comment_ID FROM $wpdb->comments WHERE comment_approved = %s", 'spam' ) );
if ( $query ) {
foreach ( $query as $id ) {
wp_delete_comment( (int) $id, true );
}
// translators: %s is the number of spam comments.
$message = sprintf( __( '%s Spam Comments Processed', 'wp-sweep' ), number_format_i18n( count( $query ) ) );
}
break;
case 'deleted_comments':
$query = $this->db( 'get_col', $wpdb->prepare( "SELECT comment_ID FROM $wpdb->comments WHERE ( comment_approved = %s OR comment_approved = %s )", 'trash', 'post-trashed' ) );
if ( $query ) {
foreach ( $query as $id ) {
wp_delete_comment( (int) $id, true );
}
// translators: %s is the number of trashed comments.
$message = sprintf( __( '%s Trash Comments Processed', 'wp-sweep' ), number_format_i18n( count( $query ) ) );
}
break;
case 'transient_options':
$query = $this->db( 'get_col', $wpdb->prepare( "SELECT option_name FROM $wpdb->options WHERE option_name LIKE %s", '%\_transient\_%' ) );
if ( $query ) {
foreach ( $query as $option_name ) {
if ( strpos( $option_name, '_site_transient_' ) !== false ) {
delete_site_transient( str_replace( '_site_transient_', '', $option_name ) );
} else {
delete_transient( str_replace( '_transient_', '', $option_name ) );
}
}
// translators: %s is the number of transient options.
$message = sprintf( __( '%s Transient Options Processed', 'wp-sweep' ), number_format_i18n( count( $query ) ) );
}
break;
case 'orphan_postmeta':
$query = $this->drop_protected_meta( $this->orphan_meta_rows( $name ), $name );
if ( $query ) {
foreach ( $query as $meta ) {
$this->delete_meta( 'post', (int) $meta->object_id, $meta->meta_key );
}
// translators: %s is the number of orphaned post meta rows.
$message = sprintf( __( '%s Orphaned Post Meta Processed', 'wp-sweep' ), number_format_i18n( count( $query ) ) );
}
break;
case 'orphan_commentmeta':
$query = $this->drop_protected_meta( $this->orphan_meta_rows( $name ), $name );
if ( $query ) {
foreach ( $query as $meta ) {
$this->delete_meta( 'comment', (int) $meta->object_id, $meta->meta_key );
}
// translators: %s is the number of orphaned comment meta rows.
$message = sprintf( __( '%s Orphaned Comment Meta Processed', 'wp-sweep' ), number_format_i18n( count( $query ) ) );
}
break;
case 'orphan_usermeta':
$query = $this->drop_protected_meta( $this->orphan_meta_rows( $name ), $name );
if ( $query ) {
foreach ( $query as $meta ) {
$this->delete_meta( 'user', (int) $meta->object_id, $meta->meta_key );
}
// translators: %s is the number of orphaned user meta rows.
$message = sprintf( __( '%s Orphaned User Meta Processed', 'wp-sweep' ), number_format_i18n( count( $query ) ) );
}
break;
case 'orphan_termmeta':
$query = $this->drop_protected_meta( $this->orphan_meta_rows( $name ), $name );
if ( $query ) {
foreach ( $query as $meta ) {
$this->delete_meta( 'term', (int) $meta->object_id, $meta->meta_key );
}
// translators: %s is the number of orphaned term meta rows.
$message = sprintf( __( '%s Orphaned Term Meta Processed', 'wp-sweep' ), number_format_i18n( count( $query ) ) );
}
break;
case 'orphan_term_relationships':
$excluded = $this->excluded_taxonomies_for_sql();
$query = $this->db(
'get_results',
$wpdb->prepare(
"SELECT tr.object_id, tr.term_taxonomy_id, tt.term_id, tt.taxonomy FROM $wpdb->term_relationships AS tr INNER JOIN $wpdb->term_taxonomy AS tt ON tr.term_taxonomy_id = tt.term_taxonomy_id WHERE tt.taxonomy NOT IN (" . implode( ', ', array_fill( 0, count( $excluded ), '%s' ) ) . ") AND tr.object_id NOT IN ( SELECT ID FROM $wpdb->posts )",
$excluded
)
);
if ( $query ) {
foreach ( $query as $tax ) {
if ( true !== wp_remove_object_terms( (int) $tax->object_id, (int) $tax->term_id, $tax->taxonomy ) ) {
$this->db( 'query', $wpdb->prepare( "DELETE FROM $wpdb->term_relationships WHERE object_id = %d AND term_taxonomy_id = %d", (int) $tax->object_id, (int) $tax->term_taxonomy_id ) );
}
}
// translators: %s is the number of orphaned term relationships.
$message = sprintf( __( '%s Orphaned Term Relationships Processed', 'wp-sweep' ), number_format_i18n( count( $query ) ) );
}
break;
case 'unused_terms':
$query = $this->unused_terms();
if ( $query ) {
$orphaned = array();
foreach ( $query as $tax ) {
if ( taxonomy_exists( $tax->taxonomy ) ) {
wp_delete_term( (int) $tax->term_id, $tax->taxonomy );
} else {
$this->db( 'query', $wpdb->prepare( "DELETE FROM $wpdb->term_taxonomy WHERE term_taxonomy_id = %d", (int) $tax->term_taxonomy_id ) );
$orphaned[] = (int) $tax->term_id;
}
}
// A term row left behind by an invalid taxonomy has nothing
// pointing at it any more and wp_delete_term() never saw it.
// Only the terms this pass just orphaned: an unqualified
// NOT IN deletes every stray wp_terms row on the site,
// which is neither what was counted nor what was ticked.
$orphaned = array_values( array_unique( $orphaned ) );
if ( $orphaned ) {
$this->db(
'query',
$wpdb->prepare(
"DELETE FROM $wpdb->terms WHERE term_id IN (" . implode( ', ', array_fill( 0, count( $orphaned ), '%d' ) ) . ") AND term_id NOT IN ( SELECT term_id FROM $wpdb->term_taxonomy )",
$orphaned
)
);
}
// translators: %s is the number of unused terms.
$message = sprintf( __( '%s Unused Terms Processed', 'wp-sweep' ), number_format_i18n( count( $query ) ) );
}
break;
case 'duplicated_postmeta':
case 'duplicated_commentmeta':
case 'duplicated_usermeta':
case 'duplicated_termmeta':
$query = $this->duplicated_meta( $name );
if ( $query ) {
foreach ( $query as $meta ) {
$this->delete_duplicate_meta( $name, $meta );
}
$message = $this->duplicated_meta_message( $name, count( $query ) );
}
break;
case 'optimize_database':
$query = $this->tables();
if ( $query ) {
$this->db( 'query', $wpdb->prepare( 'OPTIMIZE TABLE ' . implode( ', ', array_fill( 0, count( $query ), '%i' ) ), $query ) );
// translators: %s is the number of database tables.
$message = sprintf( __( '%s Tables Processed', 'wp-sweep' ), number_format_i18n( count( $query ) ) );
}
break;
case 'oembed_postmeta':
$query = $this->drop_protected_meta(
$this->db( 'get_results', $wpdb->prepare( "SELECT post_id AS object_id, meta_key FROM $wpdb->postmeta WHERE meta_key LIKE %s", self::OEMBED_LIKE ) ),
$name
);
if ( $query ) {
foreach ( $query as $meta ) {
$this->delete_meta( 'post', (int) $meta->object_id, $meta->meta_key );
}
// translators: %s is the number of oEmbed caches.
$message = sprintf( __( '%s oEmbed Caches In Post Meta Processed', 'wp-sweep' ), number_format_i18n( count( $query ) ) );
}
break;
}
/**
* Filters the message returned after a sweep has run.
*
* @since 1.0.4
*
* @param string $message The result message.
* @param string $name The sweep name.
*/
return apply_filters( 'wp_sweep_sweep', $message, $name );
}
/**
* Format a number as a percentage, taking care of division by zero.
*
* Props @barisunver https://github.com/barisunver
*
* @param int $current Current number.
* @param int $total Total number.
* @return string The number as a percentage.
*/
public function format_percentage( $current, $total ) {
return ( $total > 0 ? round( ( $current / $total ) * 100, 2 ) : 0 ) . '%';
}
/**
* Every table in the database.
*
* @return array Table names.
*/
private function tables() {
global $wpdb;
/*
* Scoped to this install's prefix. A bare SHOW TABLES is every table in
* the *schema*, and sharing one database between several WordPress
* installs -- or between WordPress and something else entirely -- is an
* ordinary hosting arrangement rather than an exotic one. So the details
* route published every co-tenant's table names and prefixes to anyone
* who could reach it, and running the sweep issued OPTIMIZE TABLE
* against installs the administrator here does not administer: a full
* rebuild on InnoDB, a write lock on MyISAM.
*
* esc_like() because a prefix may legitimately contain an underscore --
* the default one does -- and an unescaped underscore is a single
* character wildcard, which is how this would have gone on matching a
* neighbour's tables.
*/
$like = $wpdb->esc_like( $wpdb->prefix ) . '%';
$tables = (array) $this->db( 'get_col', $wpdb->prepare( 'SHOW TABLES LIKE %s', $like ) );
/**
* Filters the tables the optimize sweep operates on.
*
* The default is this install's own tables. A site with tables outside
* its prefix that it does want optimised adds them here -- and takes
* responsibility for their being its own.
*
* @since 1.2.0
*
* @param array $tables Table names.
* @param string $prefix This install's table prefix.
*/
return (array) apply_filters( 'wp_sweep_optimize_tables', $tables, $wpdb->prefix );
}
/**
* Delete one meta row, reaching past the API when it has to.
*
* The WordPress meta functions refuse to act on object ID 0, and orphaned
* meta keyed to 0 is exactly the kind this plugin exists to remove, so that
* one case is deleted with SQL instead.
*
* @param string $type One of post, comment, user or term.
* @param int $object_id The object the meta claims to belong to.
* @param string $meta_key Meta key.
* @return void
*/
private function delete_meta( $type, $object_id, $meta_key ) {
global $wpdb;
if ( 0 !== $object_id ) {
$callbacks = array(
'post' => 'delete_post_meta',
'comment' => 'delete_comment_meta',
'user' => 'delete_user_meta',
'term' => 'delete_term_meta',
);
call_user_func( $callbacks[ $type ], $object_id, $meta_key );
return;
}
switch ( $type ) {
case 'post':
$this->db( 'query', $wpdb->prepare( "DELETE FROM $wpdb->postmeta WHERE post_id = %d AND meta_key = %s", $object_id, $meta_key ) );
break;
case 'comment':
$this->db( 'query', $wpdb->prepare( "DELETE FROM $wpdb->commentmeta WHERE comment_id = %d AND meta_key = %s", $object_id, $meta_key ) );
break;
case 'user':
$this->db( 'query', $wpdb->prepare( "DELETE FROM $wpdb->usermeta WHERE user_id = %d AND meta_key = %s", $object_id, $meta_key ) );
break;
case 'term':
$this->db( 'query', $wpdb->prepare( "DELETE FROM $wpdb->termmeta WHERE term_id = %d AND meta_key = %s", $object_id, $meta_key ) );
break;
}
}
/**
* The oEmbed cache keys, with how many rows each accounts for.
*
* Shaped like orphan_meta_counts() so the same protection helpers work on
* it. This sweep was the one meta sweep that never consulted
* drop_protected_meta(), so a key a site had put on
* wp_sweep_postmeta_whitelist was deleted anyway -- the whitelist was read
* for it, since the name matches the postmeta test, and then never used.
*
* @since 1.2.0
*
* @return array Rows carrying a meta_key and a num.
*/
private function oembed_meta_counts() {
global $wpdb;
return (array) $this->db(
'get_results',
$wpdb->prepare(
"SELECT meta_key, COUNT(meta_id) AS num FROM $wpdb->postmeta WHERE meta_key LIKE %s GROUP BY meta_key",
self::OEMBED_LIKE
)
);
}
/**
* How many orphaned meta rows there are, broken down by meta key.
*
* Counting per key rather than in total is what lets the protected keys be
* dropped in PHP, where the wildcard matching already lives, instead of
* being turned into a variable-length SQL clause.
*
* @param string $name Sweep name.
* @return array Rows carrying a meta_key and a num.
*/
private function orphan_meta_counts( $name ) {
global $wpdb;
switch ( $name ) {
case 'orphan_postmeta':
return (array) $this->db( 'get_results', "SELECT meta_key, COUNT(meta_id) AS num FROM $wpdb->postmeta WHERE post_id NOT IN ( SELECT ID FROM $wpdb->posts ) GROUP BY meta_key" );
case 'orphan_commentmeta':
return (array) $this->db( 'get_results', "SELECT meta_key, COUNT(meta_id) AS num FROM $wpdb->commentmeta WHERE comment_id NOT IN ( SELECT comment_ID FROM $wpdb->comments ) GROUP BY meta_key" );
case 'orphan_usermeta':
return (array) $this->db( 'get_results', "SELECT meta_key, COUNT(umeta_id) AS num FROM $wpdb->usermeta WHERE user_id NOT IN ( SELECT ID FROM $wpdb->users ) GROUP BY meta_key" );
case 'orphan_termmeta':
return (array) $this->db( 'get_results', "SELECT meta_key, COUNT(meta_id) AS num FROM $wpdb->termmeta WHERE term_id NOT IN ( SELECT term_id FROM $wpdb->terms ) GROUP BY meta_key" );
}
return array();
}
/**
* A sample of the meta keys on the orphaned rows.
*
* @param string $name Sweep name.
* @param int $limit Maximum number of rows to read.
* @return array Meta keys.
*/
private function orphan_meta_keys( $name, $limit ) {
global $wpdb;
switch ( $name ) {
case 'orphan_postmeta':
return (array) $this->db( 'get_col', $wpdb->prepare( "SELECT meta_key FROM $wpdb->postmeta WHERE post_id NOT IN ( SELECT ID FROM $wpdb->posts ) LIMIT %d", $limit ) );
case 'orphan_commentmeta':
return (array) $this->db( 'get_col', $wpdb->prepare( "SELECT meta_key FROM $wpdb->commentmeta WHERE comment_id NOT IN ( SELECT comment_ID FROM $wpdb->comments ) LIMIT %d", $limit ) );
case 'orphan_usermeta':
return (array) $this->db( 'get_col', $wpdb->prepare( "SELECT meta_key FROM $wpdb->usermeta WHERE user_id NOT IN ( SELECT ID FROM $wpdb->users ) LIMIT %d", $limit ) );
case 'orphan_termmeta':
return (array) $this->db( 'get_col', $wpdb->prepare( "SELECT meta_key FROM $wpdb->termmeta WHERE term_id NOT IN ( SELECT term_id FROM $wpdb->terms ) LIMIT %d", $limit ) );
}
return array();
}
/**
* Every orphaned meta row, as an object id and a meta key.
*
* The id column is aliased to object_id so one loop can delete all four
* kinds without a switch inside it.
*
* @param string $name Sweep name.
* @return array Rows carrying an object_id and a meta_key.
*/
private function orphan_meta_rows( $name ) {
global $wpdb;
switch ( $name ) {
case 'orphan_postmeta':
return (array) $this->db( 'get_results', "SELECT post_id AS object_id, meta_key FROM $wpdb->postmeta WHERE post_id NOT IN ( SELECT ID FROM $wpdb->posts )" );
case 'orphan_commentmeta':
return (array) $this->db( 'get_results', "SELECT comment_id AS object_id, meta_key FROM $wpdb->commentmeta WHERE comment_id NOT IN ( SELECT comment_ID FROM $wpdb->comments )" );
case 'orphan_usermeta':
return (array) $this->db( 'get_results', "SELECT user_id AS object_id, meta_key FROM $wpdb->usermeta WHERE user_id NOT IN ( SELECT ID FROM $wpdb->users )" );
case 'orphan_termmeta':
return (array) $this->db( 'get_results', "SELECT term_id AS object_id, meta_key FROM $wpdb->termmeta WHERE term_id NOT IN ( SELECT term_id FROM $wpdb->terms )" );
}
return array();
}
/**
* How many duplicated meta rows there are, broken down by meta key.
*
* The same grouping as duplicated_meta() but none of its baggage: no
* GROUP_CONCAT buffer per group and no row ids shipped back to PHP, because
* a count needs neither. duplicated_meta() pulls every duplicate row's ids
* across the wire, and on a site whose postmeta has millions of duplicates
* that is the difference between the Sweep screen loading and timing out.
*
* @param string $name Sweep name.
* @return array Rows carrying a meta_key and a num.
*/
private function duplicated_meta_counts( $name ) {
global $wpdb;
switch ( $name ) {
case 'duplicated_postmeta':
return (array) $this->db( 'get_results', "SELECT meta_key, COUNT(meta_id) AS num FROM $wpdb->postmeta GROUP BY post_id, meta_key, meta_value HAVING num > 1" );
case 'duplicated_commentmeta':
return (array) $this->db( 'get_results', "SELECT meta_key, COUNT(meta_id) AS num FROM $wpdb->commentmeta GROUP BY comment_id, meta_key, meta_value HAVING num > 1" );
case 'duplicated_usermeta':
return (array) $this->db( 'get_results', "SELECT meta_key, COUNT(umeta_id) AS num FROM $wpdb->usermeta GROUP BY user_id, meta_key, meta_value HAVING num > 1" );
case 'duplicated_termmeta':
return (array) $this->db( 'get_results', "SELECT meta_key, COUNT(meta_id) AS num FROM $wpdb->termmeta GROUP BY term_id, meta_key, meta_value HAVING num > 1" );
}
return array();
}
/**
* Meta rows that appear more than once with the same key and value.
*
* Only sweep() reads this: the ids are what the delete needs, and hauling
* them into PHP is only worth it when they are about to be deleted. A count
* or a sample comes from duplicated_meta_counts() instead.
*
* @param string $name Sweep name.
* @return array Rows carrying ids, object_id, meta_key and num.
*/
private function duplicated_meta( $name ) {
global $wpdb;
switch ( $name ) {
case 'duplicated_postmeta':
$rows = $this->db( 'get_results', "SELECT GROUP_CONCAT(meta_id ORDER BY meta_id DESC) AS ids, post_id AS object_id, meta_key, COUNT(meta_id) AS num FROM $wpdb->postmeta GROUP BY post_id, meta_key, meta_value HAVING num > 1" );
break;
case 'duplicated_commentmeta':
$rows = $this->db( 'get_results', "SELECT GROUP_CONCAT(meta_id ORDER BY meta_id DESC) AS ids, comment_id AS object_id, meta_key, COUNT(meta_id) AS num FROM $wpdb->commentmeta GROUP BY comment_id, meta_key, meta_value HAVING num > 1" );
break;
case 'duplicated_usermeta':
$rows = $this->db( 'get_results', "SELECT GROUP_CONCAT(umeta_id ORDER BY umeta_id DESC) AS ids, user_id AS object_id, meta_key, COUNT(umeta_id) AS num FROM $wpdb->usermeta GROUP BY user_id, meta_key, meta_value HAVING num > 1" );
break;
case 'duplicated_termmeta':
$rows = $this->db( 'get_results', "SELECT GROUP_CONCAT(meta_id ORDER BY meta_id DESC) AS ids, term_id AS object_id, meta_key, COUNT(meta_id) AS num FROM $wpdb->termmeta GROUP BY term_id, meta_key, meta_value HAVING num > 1" );
break;
default:
return array();
}
return $this->drop_protected_meta( (array) $rows, $name );
}
/**
* Delete every copy of a duplicated meta row but the oldest.
*
* @param string $name Sweep name.
* @param object $meta One row from duplicated_meta().
* @return void
*/
private function delete_duplicate_meta( $name, $meta ) {
global $wpdb;
$ids = array_map( 'intval', explode( ',', $meta->ids ) );
// GROUP_CONCAT ordered them newest first, so popping the last one keeps
// the row that was there originally.
array_pop( $ids );
if ( empty( $ids ) ) {
return;
}
$args = array_merge( $ids, array( (int) $meta->object_id ) );
switch ( $name ) {
case 'duplicated_postmeta':
$this->db( 'query', $wpdb->prepare( "DELETE FROM $wpdb->postmeta WHERE meta_id IN (" . implode( ', ', array_fill( 0, count( $ids ), '%d' ) ) . ') AND post_id = %d', $args ) );
break;
case 'duplicated_commentmeta':
$this->db( 'query', $wpdb->prepare( "DELETE FROM $wpdb->commentmeta WHERE meta_id IN (" . implode( ', ', array_fill( 0, count( $ids ), '%d' ) ) . ') AND comment_id = %d', $args ) );
break;
case 'duplicated_usermeta':
$this->db( 'query', $wpdb->prepare( "DELETE FROM $wpdb->usermeta WHERE umeta_id IN (" . implode( ', ', array_fill( 0, count( $ids ), '%d' ) ) . ') AND user_id = %d', $args ) );
break;
case 'duplicated_termmeta':
$this->db( 'query', $wpdb->prepare( "DELETE FROM $wpdb->termmeta WHERE meta_id IN (" . implode( ', ', array_fill( 0, count( $ids ), '%d' ) ) . ') AND term_id = %d', $args ) );
break;
}
}
/**
* The message a duplicated meta sweep reports.
*
* @param string $name Sweep name.
* @param int $count Number of rows processed.
* @return string Translated message.
*/
private function duplicated_meta_message( $name, $count ) {
$count = number_format_i18n( $count );
switch ( $name ) {
case 'duplicated_postmeta':
// translators: %s is the number of duplicated post meta rows.
return sprintf( __( '%s Duplicated Post Meta Processed', 'wp-sweep' ), $count );
case 'duplicated_commentmeta':
// translators: %s is the number of duplicated comment meta rows.
return sprintf( __( '%s Duplicated Comment Meta Processed', 'wp-sweep' ), $count );
case 'duplicated_usermeta':
// translators: %s is the number of duplicated user meta rows.
return sprintf( __( '%s Duplicated User Meta Processed', 'wp-sweep' ), $count );
case 'duplicated_termmeta':
// translators: %s is the number of duplicated term meta rows.
return sprintf( __( '%s Duplicated Term Meta Processed', 'wp-sweep' ), $count );
}
return '';
}
/**
* The taxonomies the orphaned term relationships sweep leaves alone.
*
* The wp_sweep_excluded_taxonomies filter is public API, so a site is
* entitled to filter it down to nothing. NOT IN () is a syntax error rather
* than a clause that matches everything, and the empty count that produced
* was rendered as a blank cell. A taxonomy name can never be the empty
* string, so carrying one keeps the list valid and excludes nothing.
*
* The taxonomies of non-post objects are added to it, because the sweep's
* test for an orphan is "no row in wp_posts with this ID" — and for a
* taxonomy registered against users or comments, object_id is a user or a
* comment ID. Those numbers collide with post IDs by accident and nothing
* else, so every relationship whose user happened to outnumber the posts
* looked orphaned and was deleted. link_category is the case core ships and
* the reason the hardcoded exclusion above exists; this generalises it to
* whatever the site has registered.
*
* A taxonomy nothing has registered is still swept. Its object type is
* unknowable and a leftover row is exactly what the sweep is for.
*
* @return array Taxonomy names, never empty.
*/
private function excluded_taxonomies_for_sql() {
$excluded = array_map( 'strval', (array) $this->get_excluded_taxonomies() );
$excluded[] = '';
return array_values( array_unique( array_merge( $excluded, $this->non_post_taxonomies() ) ) );
}
/**
* Registered taxonomies attached to something other than a post type.
*
* A taxonomy with no object type at all counts as one: nothing says its
* object IDs are posts either.
*
* @return array Taxonomy names.
*/
private function non_post_taxonomies() {
$names = array();
foreach ( get_taxonomies( array(), 'objects' ) as $taxonomy ) {
$objects = (array) $taxonomy->object_type;
if ( ! $objects ) {
$names[] = $taxonomy->name;
continue;
}
foreach ( $objects as $object ) {
if ( ! post_type_exists( $object ) ) {
$names[] = $taxonomy->name;
break;
}
}
}
return $names;
}
/**
* Terms attached to nothing, minus the ones that must be kept.
*
* A count of zero is not on its own the question. Core's own counter,
* _update_post_term_count(), counts posts that are published, so a term
* used only by drafts, pending posts, private posts or posts in the trash
* has a count of zero while being very much in use — and wp_delete_term()
* takes its relationships with it, so those posts come back from the trash
* uncategorised. Requiring the relationship table to be empty too is the
* literal reading of "attached to nothing".
*
* The exclusions are applied here rather than in SQL because the list is
* filterable and can legitimately be empty, and because the terms tables
* are small enough that reading them costs nothing.
*
* @return array Rows carrying a term_taxonomy_id, a term_id, a name and a taxonomy.
*/
private function unused_terms() {
global $wpdb;
$rows = (array) $this->db( 'get_results', "SELECT tt.term_taxonomy_id, t.term_id, t.name, tt.taxonomy FROM $wpdb->terms AS t INNER JOIN $wpdb->term_taxonomy AS tt ON t.term_id = tt.term_id WHERE tt.count = 0 AND NOT EXISTS ( SELECT 1 FROM $wpdb->term_relationships AS tr WHERE tr.term_taxonomy_id = tt.term_taxonomy_id )" );
$excluded = array_flip( array_filter( array_map( 'intval', (array) $this->get_excluded_termids() ) ) );
if ( empty( $excluded ) ) {
return $rows;
}
return array_values(
array_filter(
$rows,
static function ( $row ) use ( $excluded ) {
return ! isset( $excluded[ (int) $row->term_id ] );
}
)
);
}
/**
* Taxonomies kept out of the orphaned term relationships sweep.
*
* @return array Excluded taxonomy names.
*/
private function get_excluded_taxonomies() {
$excluded_taxonomies = array( 'link_category' );
/**
* Filters the taxonomies excluded from the orphaned term relationships sweep.
*
* @since 1.0.8
*
* @param array $excluded_taxonomies Taxonomy names.
*/
return apply_filters( 'wp_sweep_excluded_taxonomies', $excluded_taxonomies );
}
/**
* Term IDs kept out of the unused terms sweep.
*
* @return array Excluded term IDs.
*/
private function get_excluded_termids() {
$excluded_termids = array_merge(
$this->get_default_taxonomy_termids(),
$this->get_parent_termids()
);
/**
* Filters the term IDs excluded from the unused terms sweep.
*
* @since 1.1.1
*
* @param array $excluded_termids Term IDs.
*/
return apply_filters( 'wp_sweep_excluded_termids', $excluded_termids );
}
/**
* The term each taxonomy nominates as its default.
*
* A `default_<taxonomy>` option is only worth protecting if it still names a
* term that exists in that taxonomy. WordPress ships `default_link_category`
* set to 2, pointing at the "Blogroll" term that installs have not had since
* the Links Manager was retired in 3.5 -- so before 2.0.0 this returned 2 on
* essentially every site, and whatever term happened to hold ID 2 was
* silently excluded from the unused terms sweep forever.
*
* term_exists() checks the taxonomy as well as the ID, so an option left
* pointing at a term that has since moved taxonomies no longer protects an
* unrelated one.
*
* @return array Default taxonomy term IDs.
*/
private function get_default_taxonomy_termids() {
$default_term_ids = array();
foreach ( array_keys( get_taxonomies() ) as $taxonomy ) {
// Built first rather than inline, so a scan for the plugin's own
// option rows does not read this core one as one of them.
$option = 'default_' . $taxonomy;
$term_id = (int) get_option( $option );
if ( $term_id > 0 && null !== term_exists( $term_id, $taxonomy ) ) {
$default_term_ids[] = $term_id;
}
}
return $default_term_ids;
}
/**
* Terms that are the parent of another term.
*
* @return array Parent term IDs.
*/
private function get_parent_termids() {
global $wpdb;
return (array) $this->db( 'get_col', "SELECT tt.parent FROM $wpdb->terms AS t INNER JOIN $wpdb->term_taxonomy AS tt ON t.term_id = tt.term_id WHERE tt.parent > 0" );
}
/**
* The meta keys a site has asked never to be deleted.
*
* @param string $name Sweep name.
* @return array Meta key patterns, `*` meaning any run of characters.
*/
private function get_protected_meta_keys( $name ) {
if ( strpos( $name, 'postmeta' ) !== false ) {
/**
* Filters the post meta keys WP-Sweep must never delete.
*
* @since 1.2.0
*
* @param array $meta_keys Meta keys, `*` matching any run of characters.
*/
return (array) apply_filters( 'wp_sweep_postmeta_whitelist', array() );
}
if ( strpos( $name, 'commentmeta' ) !== false ) {
/**
* Filters the comment meta keys WP-Sweep must never delete.
*
* @since 1.2.0
*
* @param array $meta_keys Meta keys, `*` matching any run of characters.
*/
return (array) apply_filters( 'wp_sweep_commentmeta_whitelist', array() );
}
if ( strpos( $name, 'usermeta' ) !== false ) {
/**
* Filters the user meta keys WP-Sweep must never delete.
*
* @since 1.2.0
*
* @param array $meta_keys Meta keys, `*` matching any run of characters.
*/
return (array) apply_filters( 'wp_sweep_usermeta_whitelist', array() );
}
if ( strpos( $name, 'termmeta' ) !== false ) {
/**
* Filters the term meta keys WP-Sweep must never delete.
*
* @since 1.2.0
*
* @param array $meta_keys Meta keys, `*` matching any run of characters.
*/
return (array) apply_filters( 'wp_sweep_termmeta_whitelist', array() );
}
return array();
}
/**
* Whether a meta key is protected from this sweep.
*
* Matching is done in PHP rather than with SQL LIKE, which treated an
* underscore in a pattern as a single-character wildcard. Meta keys are
* full of underscores, so `_my_key` protected a great deal more than it
* looked like it did.
*
* @param string $meta_key Meta key.
* @param array $patterns Protected patterns.
* @return bool Whether the key must be kept.
*/
private function is_meta_key_protected( $meta_key, $patterns ) {
foreach ( $patterns as $pattern ) {
$pattern = (string) $pattern;
if ( strpos( $pattern, '*' ) === false ) {
if ( $meta_key === $pattern ) {
return true;
}
continue;
}
$regex = '/^' . str_replace( '\*', '.*', preg_quote( $pattern, '/' ) ) . '$/';
if ( preg_match( $regex, $meta_key ) ) {
return true;
}
}
return false;
}
/**
* Drop the protected keys from a list of rows or meta keys.
*
* Accepts either plain meta keys or rows carrying a meta_key property, so
* count(), details() and sweep() all filter the same way.
*
* @param array $items Meta keys, or rows with a meta_key property.
* @param string $name Sweep name.
* @return array The items that may be deleted.
*/
private function drop_protected_meta( $items, $name ) {
$protected = $this->get_protected_meta_keys( $name );
if ( empty( $protected ) ) {
return $items;
}
$kept = array();
foreach ( $items as $item ) {
$meta_key = is_object( $item ) ? $item->meta_key : $item;
if ( ! $this->is_meta_key_protected( (string) $meta_key, $protected ) ) {
$kept[] = $item;
}
}
return $kept;
}
/**
* Sum a per-key breakdown, skipping the keys a site has protected.
*
* @param array $rows Rows carrying a meta_key and a num.
* @param string $name Sweep name.
* @return int The number of rows that may be deleted.
*/
private function count_protected_meta( $rows, $name ) {
$count = 0;
foreach ( $this->drop_protected_meta( $rows, $name ) as $row ) {
$count += (int) $row->num;
}
return $count;
}
}