array(), // Item keys to clean every week. 'days' => 30, // Age for revisions, trash and logs. 'last_run' => 0, 'last_log' => array(), // key => removed, from the last run. ); } public function settings() { if ( null === $this->settings ) { $saved = get_option( self::OPTION, array() ); $this->settings = wp_parse_args( is_array( $saved ) ? $saved : array(), self::defaults() ); } return $this->settings; } public function init() { add_filter( 'bylt_settings_tabs', array( $this, 'settings_tab' ) ); add_action( 'bylt_settings_tab_db-cleanup', array( $this, 'render_settings' ) ); add_action( 'admin_post_bylt_db_cleanup', array( $this, 'handle' ) ); add_action( self::CRON, array( $this, 'run_weekly' ) ); if ( $this->settings()['weekly'] && ! wp_next_scheduled( self::CRON ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'weekly', self::CRON ); } elseif ( ! $this->settings()['weekly'] && wp_next_scheduled( self::CRON ) ) { wp_clear_scheduled_hook( self::CRON ); } } /* --------------------------------------------------------------------- * Items * ------------------------------------------------------------------- */ private function cutoff() { return gmdate( 'Y-m-d H:i:s', time() - max( 1, (int) $this->settings()['days'] ) * DAY_IN_SECONDS ); } private static function table_exists( $table ) { global $wpdb; return $table === $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } /** Taxonomies used only by post types (term links to missing posts are safe to remove). */ private static function post_taxonomies() { $out = array(); $types = get_post_types(); foreach ( get_taxonomies( array(), 'objects' ) as $tax ) { $objects = (array) $tax->object_type; if ( $objects && ! array_diff( $objects, $types ) && 'link_category' !== $tax->name ) { $out[] = $tax->name; } } return $out ? $out : array( 'category', 'post_tag' ); } private static function tax_in() { global $wpdb; $out = array(); foreach ( self::post_taxonomies() as $t ) { $out[] = $wpdb->prepare( '%s', $t ); } return implode( ',', $out ); } /** Post types whose trash is never emptied here (orders and the like). */ private static function protected_types() { return array( 'shop_order', 'shop_order_refund', 'shop_order_placehold', 'shop_subscription', 'shop_coupon', 'product', 'product_variation', 'wp_block', 'wp_template', 'wp_template_part', 'wp_navigation', 'wp_global_styles' ); } /** key => array( label, help ). */ public function items() { $days = (int) $this->settings()['days']; $out = array( /* translators: %d: days */ 'revisions' => array( __( 'Old revisions', 'byltcommerce-toolkit' ), sprintf( __( 'Saved copies of posts and pages older than %d days. The current version is kept.', 'byltcommerce-toolkit' ), $days ) ), 'autodrafts' => array( __( 'Auto-drafts', 'byltcommerce-toolkit' ), __( 'Empty drafts WordPress makes when you open "Add new" and leave (older than 7 days).', 'byltcommerce-toolkit' ) ), /* translators: %d: days */ 'trash' => array( __( 'Posts and pages in the trash', 'byltcommerce-toolkit' ), sprintf( __( 'Trashed more than %d days ago. Orders, products and coupons in the trash are left alone.', 'byltcommerce-toolkit' ), $days ) ), 'spam' => array( __( 'Spam and trashed comments', 'byltcommerce-toolkit' ), __( 'Comments marked spam or trashed. Order notes and reviews that aren\'t spam are kept.', 'byltcommerce-toolkit' ) ), 'transients' => array( __( 'Expired transients', 'byltcommerce-toolkit' ), __( 'Temporary cached data that has passed its expiry date.', 'byltcommerce-toolkit' ) ), 'oembed' => array( __( 'Embed caches', 'byltcommerce-toolkit' ), __( 'Cached YouTube/Twitter embed code; WordPress rebuilds it when a page is viewed.', 'byltcommerce-toolkit' ) ), 'orphan_post' => array( __( 'Orphaned post data', 'byltcommerce-toolkit' ), __( 'Extra fields left behind by posts and products that no longer exist.', 'byltcommerce-toolkit' ) ), 'orphan_user' => array( __( 'Orphaned user data', 'byltcommerce-toolkit' ), __( 'Extra fields left behind by deleted users.', 'byltcommerce-toolkit' ) ), 'orphan_other' => array( __( 'Other orphaned data', 'byltcommerce-toolkit' ), __( 'Comment and term fields, and category links, for things that no longer exist.', 'byltcommerce-toolkit' ) ), ); if ( class_exists( 'WooCommerce' ) ) { $out['wc_sessions'] = array( __( 'Expired WooCommerce sessions', 'byltcommerce-toolkit' ), __( 'Carts of visitors who left; WooCommerce only clears these slowly.', 'byltcommerce-toolkit' ) ); } global $wpdb; if ( self::table_exists( $wpdb->prefix . 'actionscheduler_actions' ) ) { /* translators: %d: days */ $out['actions'] = array( __( 'Old scheduled-action history', 'byltcommerce-toolkit' ), sprintf( __( 'Finished, failed and cancelled background tasks (and their logs) older than %d days. Pending tasks are kept.', 'byltcommerce-toolkit' ), $days ) ); } return $out; } /** How many rows an item would remove. */ public function count( $key ) { global $wpdb; $cut = $this->cutoff(); // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- table names from $wpdb, id lists cast with intval(), taxonomy list built with prepare() in tax_in(). switch ( $key ) { case 'revisions': return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'revision' AND post_modified_gmt < %s", $cut ) ); case 'autodrafts': return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status = 'auto-draft' AND post_modified_gmt < %s", gmdate( 'Y-m-d H:i:s', time() - 7 * DAY_IN_SECONDS ) ) ); case 'trash': return count( $this->trash_ids( 100000 ) ); case 'spam': return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->comments} WHERE comment_approved IN ('spam','trash')" ); case 'transients': return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->options} WHERE (option_name LIKE %s OR option_name LIKE %s) AND option_value < %d", $wpdb->esc_like( '_transient_timeout_' ) . '%', $wpdb->esc_like( '_site_transient_timeout_' ) . '%', time() ) ); case 'oembed': return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key LIKE %s", $wpdb->esc_like( '_oembed_' ) . '%' ) ); case 'orphan_post': return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->postmeta} pm LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE p.ID IS NULL" ); case 'orphan_user': return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->usermeta} um LEFT JOIN {$wpdb->users} u ON u.ID = um.user_id WHERE u.ID IS NULL" ); case 'orphan_other': return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->commentmeta} cm LEFT JOIN {$wpdb->comments} c ON c.comment_ID = cm.comment_id WHERE c.comment_ID IS NULL" ) + (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->termmeta} tm LEFT JOIN {$wpdb->terms} t ON t.term_id = tm.term_id WHERE t.term_id IS NULL" ) + (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->term_relationships} tr LEFT JOIN {$wpdb->posts} p ON p.ID = tr.object_id INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id WHERE p.ID IS NULL AND tt.taxonomy IN (" . self::tax_in() . ')' ); case 'wc_sessions': $t = $wpdb->prefix . 'woocommerce_sessions'; return self::table_exists( $t ) ? (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$t} WHERE session_expiry < %d", time() ) ) : 0; case 'actions': $t = $wpdb->prefix . 'actionscheduler_actions'; return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$t} WHERE status IN ('complete','failed','canceled') AND scheduled_date_gmt < %s", $cut ) ); } // phpcs:enable return 0; } private function trash_ids( $limit ) { global $wpdb; $types = self::protected_types(); $in = implode( ',', array_fill( 0, count( $types ), '%s' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- %s placeholders built above; values passed as one array. return array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'trash' AND post_modified_gmt < %s AND post_type NOT IN ({$in}) LIMIT %d", array_merge( array( $this->cutoff() ), $types, array( (int) $limit ) ) ) ) ); } /** * Remove one item's rows, in batches, for up to ~20 seconds. * * @return int Rows removed. */ public function clean( $key ) { global $wpdb; $cut = $this->cutoff(); $stop = microtime( true ) + 20; $total = 0; // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- table names from $wpdb, id lists cast with intval(), taxonomy list built with prepare() in tax_in(). do { $n = 0; switch ( $key ) { case 'revisions': foreach ( $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'revision' AND post_modified_gmt < %s LIMIT %d", $cut, self::BATCH ) ) as $id ) { $n += wp_delete_post_revision( (int) $id ) ? 1 : 0; } break; case 'autodrafts': foreach ( $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'auto-draft' AND post_modified_gmt < %s LIMIT %d", gmdate( 'Y-m-d H:i:s', time() - 7 * DAY_IN_SECONDS ), self::BATCH ) ) as $id ) { $n += wp_delete_post( (int) $id, true ) ? 1 : 0; } break; case 'trash': foreach ( $this->trash_ids( self::BATCH ) as $id ) { $n += wp_delete_post( $id, true ) ? 1 : 0; } break; case 'spam': foreach ( $wpdb->get_col( $wpdb->prepare( "SELECT comment_ID FROM {$wpdb->comments} WHERE comment_approved IN ('spam','trash') LIMIT %d", self::BATCH ) ) as $id ) { $n += wp_delete_comment( (int) $id, true ) ? 1 : 0; } break; case 'transients': $before = $this->count( 'transients' ); delete_expired_transients( true ); $n = 0; $total = max( 0, $before - $this->count( 'transients' ) ); break; case 'oembed': $n = (int) $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->postmeta} WHERE meta_key LIKE %s LIMIT %d", $wpdb->esc_like( '_oembed_' ) . '%', self::BATCH ) ); break; case 'orphan_post': $ids = $wpdb->get_col( $wpdb->prepare( "SELECT pm.meta_id FROM {$wpdb->postmeta} pm LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE p.ID IS NULL LIMIT %d", self::BATCH ) ); $n = $ids ? (int) $wpdb->query( "DELETE FROM {$wpdb->postmeta} WHERE meta_id IN (" . implode( ',', array_map( 'intval', $ids ) ) . ')' ) : 0; break; case 'orphan_user': $ids = $wpdb->get_col( $wpdb->prepare( "SELECT um.umeta_id FROM {$wpdb->usermeta} um LEFT JOIN {$wpdb->users} u ON u.ID = um.user_id WHERE u.ID IS NULL LIMIT %d", self::BATCH ) ); $n = $ids ? (int) $wpdb->query( "DELETE FROM {$wpdb->usermeta} WHERE umeta_id IN (" . implode( ',', array_map( 'intval', $ids ) ) . ')' ) : 0; break; case 'orphan_other': $ids = $wpdb->get_col( $wpdb->prepare( "SELECT cm.meta_id FROM {$wpdb->commentmeta} cm LEFT JOIN {$wpdb->comments} c ON c.comment_ID = cm.comment_id WHERE c.comment_ID IS NULL LIMIT %d", self::BATCH ) ); $n += $ids ? (int) $wpdb->query( "DELETE FROM {$wpdb->commentmeta} WHERE meta_id IN (" . implode( ',', array_map( 'intval', $ids ) ) . ')' ) : 0; $ids = $wpdb->get_col( $wpdb->prepare( "SELECT tm.meta_id FROM {$wpdb->termmeta} tm LEFT JOIN {$wpdb->terms} t ON t.term_id = tm.term_id WHERE t.term_id IS NULL LIMIT %d", self::BATCH ) ); $n += $ids ? (int) $wpdb->query( "DELETE FROM {$wpdb->termmeta} WHERE meta_id IN (" . implode( ',', array_map( 'intval', $ids ) ) . ')' ) : 0; $rel = $wpdb->get_results( $wpdb->prepare( "SELECT tr.object_id, tr.term_taxonomy_id FROM {$wpdb->term_relationships} tr LEFT JOIN {$wpdb->posts} p ON p.ID = tr.object_id INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id WHERE p.ID IS NULL AND tt.taxonomy IN (" . self::tax_in() . ') LIMIT %d', self::BATCH ) ); foreach ( (array) $rel as $r ) { $n += (int) $wpdb->delete( $wpdb->term_relationships, array( 'object_id' => (int) $r->object_id, 'term_taxonomy_id' => (int) $r->term_taxonomy_id ) ); } break; case 'wc_sessions': $t = $wpdb->prefix . 'woocommerce_sessions'; $n = self::table_exists( $t ) ? (int) $wpdb->query( $wpdb->prepare( "DELETE FROM {$t} WHERE session_expiry < %d LIMIT %d", time(), self::BATCH * 4 ) ) : 0; break; case 'actions': $t = $wpdb->prefix . 'actionscheduler_actions'; $l = $wpdb->prefix . 'actionscheduler_logs'; $ids = array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( "SELECT action_id FROM {$t} WHERE status IN ('complete','failed','canceled') AND scheduled_date_gmt < %s LIMIT %d", $cut, self::BATCH * 2 ) ) ); if ( $ids ) { $in = implode( ',', $ids ); if ( self::table_exists( $l ) ) { $wpdb->query( "DELETE FROM {$l} WHERE action_id IN ({$in})" ); } $n = (int) $wpdb->query( "DELETE FROM {$t} WHERE action_id IN ({$in})" ); } break; } $total += $n; } while ( $n > 0 && microtime( true ) < $stop ); // phpcs:enable return $total; } /** Size of this site's tables in bytes (when the host allows reading it). */ public static function db_size() { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery $size = $wpdb->get_var( $wpdb->prepare( 'SELECT SUM(data_length + index_length) FROM information_schema.TABLES WHERE table_schema = %s AND table_name LIKE %s', $wpdb->dbname, $wpdb->esc_like( $wpdb->prefix ) . '%' ) ); return null === $size ? 0 : (int) $size; } public function run_weekly() { $s = $this->settings(); $log = array(); foreach ( (array) $s['weekly'] as $key ) { if ( isset( $this->items()[ $key ] ) ) { $log[ $key ] = $this->clean( $key ); } } $this->remember( $log ); $this->log( 'db_cleanup', 'Weekly database cleanup: ' . self::summary( $log ) . '.' ); } private function remember( array $log ) { $s = $this->settings(); $s['last_run'] = time(); $s['last_log'] = $log; update_option( self::OPTION, $s, false ); $this->settings = null; } private static function summary( array $log ) { $bits = array(); foreach ( $log as $k => $n ) { $bits[] = $k . ' ' . (int) $n; } return $bits ? implode( ', ', $bits ) : 'nothing selected'; } /* --------------------------------------------------------------------- * Screen * ------------------------------------------------------------------- */ public function settings_tab( $tabs ) { $tabs['db-cleanup'] = __( 'Database Cleanup', 'byltcommerce-toolkit' ); return $tabs; } public function render_settings() { $s = $this->settings(); $items = $this->items(); // phpcs:disable WordPress.Security.NonceVerification -- display only. if ( isset( $_GET['cleaned'] ) ) { $n = absint( $_GET['cleaned'] ); /* translators: %s: number of rows */ echo '

' . esc_html( sprintf( _n( 'Removed %s row.', 'Removed %s rows.', $n, 'byltcommerce-toolkit' ), number_format_i18n( $n ) ) ) . ( ! empty( $_GET['more'] ) ? ' ' . esc_html__( 'There\'s more left; press Clean again to continue.', 'byltcommerce-toolkit' ) : '' ) . '

'; } elseif ( isset( $_GET['updated'] ) ) { echo '

' . esc_html__( 'Settings saved.', 'byltcommerce-toolkit' ) . '

'; } // phpcs:enable $size = self::db_size(); echo '

' . esc_html__( 'Nothing is removed until you press Clean. Orders, order notes, customers, products and coupons are never touched. It\'s still a good idea to have a recent backup.', 'byltcommerce-toolkit' ) . '

'; if ( $size ) { /* translators: %s: size like "48 MB" */ echo '

' . esc_html( sprintf( __( 'Database size: %s', 'byltcommerce-toolkit' ), size_format( $size, 1 ) ) ) . '

'; } echo '
'; wp_nonce_field( 'bylt_db_cleanup' ); echo ''; $total = 0; foreach ( $items as $k => $it ) { $n = $this->count( $k ); $total += $n; echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
' . esc_html__( 'What', 'byltcommerce-toolkit' ) . '' . esc_html__( 'To remove', 'byltcommerce-toolkit' ) . '' . esc_html__( 'Every week', 'byltcommerce-toolkit' ) . '
' . esc_html( $it[0] ) . '
' . esc_html( $it[1] ) . '
' . ( $n ? '' . esc_html( number_format_i18n( $n ) ) . '' : '0' ) . '' . ( $n ? '' : '' ) . '
'; echo '

'; echo ' '; echo '

'; if ( $s['last_run'] ) { /* translators: 1: date, 2: summary */ echo '

' . esc_html( sprintf( __( 'Last cleanup: %1$s (%2$s).', 'byltcommerce-toolkit' ), wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), (int) $s['last_run'] ), self::summary( (array) $s['last_log'] ) ) ) . '

'; } $next = wp_next_scheduled( self::CRON ); if ( $next ) { /* translators: %s: date */ echo '

' . esc_html( sprintf( __( 'Next weekly cleanup: %s.', 'byltcommerce-toolkit' ), wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $next ) ) ) . '

'; } echo '
'; } public function handle() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Only administrators can clean the database.', 'byltcommerce-toolkit' ), 403 ); } check_admin_referer( 'bylt_db_cleanup' ); $s = $this->settings(); $valid = array_keys( $this->items() ); // phpcs:disable WordPress.Security.NonceVerification -- checked above. $s['weekly'] = isset( $_POST['weekly'] ) ? array_values( array_intersect( array_map( 'sanitize_key', (array) wp_unslash( $_POST['weekly'] ) ), $valid ) ) : array(); $s['days'] = isset( $_POST['days'] ) ? min( 3650, max( 1, absint( $_POST['days'] ) ) ) : 30; $one = isset( $_POST['clean_one'] ) ? sanitize_key( wp_unslash( $_POST['clean_one'] ) ) : ''; $keys = $one ? array( $one ) : ( isset( $_POST['do'] ) && 'clean' === $_POST['do'] && isset( $_POST['items'] ) ? array_map( 'sanitize_key', (array) wp_unslash( $_POST['items'] ) ) : array() ); // phpcs:enable update_option( self::OPTION, $s, false ); $this->settings = null; wp_clear_scheduled_hook( self::CRON ); if ( $s['weekly'] ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'weekly', self::CRON ); } $keys = array_values( array_intersect( $keys, $valid ) ); if ( ! $keys ) { wp_safe_redirect( Bylt_Admin::url( 'db-cleanup', array( 'updated' => 1 ) ) ); exit; } $log = array(); $more = false; foreach ( $keys as $k ) { $log[ $k ] = $this->clean( $k ); $more = $more || $this->count( $k ) > 0; } $this->remember( $log ); $this->log( 'db_cleanup', 'Database cleanup: ' . self::summary( $log ) . '.' ); wp_safe_redirect( Bylt_Admin::url( 'db-cleanup', array( 'cleaned' => array_sum( $log ), 'more' => $more ? 1 : 0 ) ) ); exit; } }