<?php
/**
* Plugin Name: My Pro ID Image Cleanup (Enhanced Deletion Features)
* Description: Provides settings to schedule/run jobs deleting unused images, empty my-pro-id posts, normal posts (with options for all or non-admin), my-pro-id posts without phone numbers, and dangerous bulk deletion options.
* Version: 1.7
* Author: Your Name
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly
}
class My_Pro_ID_Image_Cleanup {
private static $instance = null;
private $option_name = 'my_pro_id_cleanup_settings';
private $log_option_name = 'my_pro_id_cleanup_logs'; // Stores image deletion logs
private $post_log_option = 'my_pro_id_empty_post_logs'; // Stores my-pro-id post (empty image) deletion logs
private $normal_post_log_option = 'my_pro_id_normal_post_logs'; // Stores normal post (non-admin) deletion logs
private $all_normal_post_log_option = 'my_pro_id_all_normal_post_logs'; // NEW: Stores all normal post deletion logs
private $no_phone_post_log_option = 'my_pro_id_no_phone_post_logs'; // NEW: Stores my-pro-id post (no phone) deletion logs
private $all_posts_non_admin_media_log_option = 'my_pro_id_all_posts_non_admin_media_logs'; // NEW: Stores log for deleting all posts & non-admin media
private $cron_hook = 'my_pro_id_cleanup_cron_event';
/**
* Singleton pattern
*/
public static function init() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor
*/
private function __construct() {
// Activation/Deactivation hooks
register_activation_hook( __FILE__, array( $this, 'on_plugin_activation' ) );
register_deactivation_hook( __FILE__, array( $this, 'on_plugin_deactivation' ) );
// Admin menu & settings
add_action( 'admin_menu', array( $this, 'add_settings_page' ) );
add_action( 'admin_init', array( $this, 'register_settings' ) );
// Cron job
add_action( $this->cron_hook, array( $this, 'cron_cleanup_callback' ) );
}
/**
* Plugin activation: schedule the cron event based on default or saved settings.
*/
public function on_plugin_activation() {
$this->schedule_cron_job();
}
/**
* Plugin deactivation: remove scheduled cron event.
*/
public function on_plugin_deactivation() {
$timestamp = wp_next_scheduled( $this->cron_hook );
if ( $timestamp ) {
wp_unschedule_event( $timestamp, $this->cron_hook );
}
}
/**
* Set up or update the cron job based on current settings.
*/
private function schedule_cron_job() {
// Clear any existing schedules first
$timestamp = wp_next_scheduled( $this->cron_hook );
if ( $timestamp ) {
wp_unschedule_event( $timestamp, $this->cron_hook );
}
// Get settings
$settings = get_option( $this->option_name, array() );
$schedule = isset( $settings['schedule'] ) ? $settings['schedule'] : 'daily';
$hour = isset( $settings['time'] ) ? intval( $settings['time'] ) : 2; // 2 AM default
// Add custom schedules for monthly or weekly if needed
add_filter( 'cron_schedules', array( $this, 'add_cron_schedules' ) );
// Calculate the next run
$next_run = $this->get_next_run_timestamp( $schedule, $hour );
if ($next_run && $schedule) { // Ensure we have valid values before scheduling
wp_schedule_event( $next_run, $schedule, $this->cron_hook );
}
}
/**
* Determine next run timestamp for the selected schedule and hour
*/
private function get_next_run_timestamp( $schedule, $hour ) {
$current_time = current_time( 'timestamp' );
$today = strtotime( 'today ' . $hour . ':00:00', $current_time );
// If today's hour is already passed, schedule for tomorrow
if ( $current_time >= $today ) {
$start_time = strtotime( 'tomorrow ' . $hour . ':00:00' );
} else {
$start_time = $today;
}
// Adjust for weekly/monthly if the first calculated time is not correct for the interval logic
// This basic calculation works for daily, but weekly/monthly might need more complex logic
// depending on the exact day/date desired. For simplicity, we rely on wp_schedule_event's recurrence.
return $start_time;
}
/**
* Add custom cron schedules for weekly and monthly
*/
public function add_cron_schedules( $schedules ) {
if ( ! isset( $schedules['weekly'] ) ) {
$schedules['weekly'] = array(
'interval' => 7 * 24 * 3600,
'display' => __( 'Once Weekly', 'my-pro-id' ),
);
}
if ( ! isset( $schedules['monthly'] ) ) {
// Approx 30 days. Adjust if needed.
$schedules['monthly'] = array(
'interval' => 30 * 24 * 3600,
'display' => __( 'Once Monthly', 'my-pro-id' ),
);
}
return $schedules;
}
/**
* The Cron job callback: auto-delete unattached images in batches
*/
public function cron_cleanup_callback() {
$settings = get_option( $this->option_name, array() );
$batch = ! empty( $settings['batch_size'] ) ? intval( $settings['batch_size'] ) : 500;
$this->delete_unattached_images( $batch );
}
/**
* Delete unattached images in batches up to $limit
*/
private function delete_unattached_images( $limit ) {
// 1. Gather used image IDs
$used_image_ids = $this->get_used_image_ids();
// 2. Query unattached attachments
$query_args = array(
'post_type' => 'attachment',
'post_status' => 'inherit', // Attachments usually have 'inherit' status
'posts_per_page' => $limit,
'orderby' => 'date',
'order' => 'DESC',
'post_parent' => 0, // unattached
'fields' => 'ids' // Only get IDs
);
$attachments = get_posts( $query_args );
if ( empty( $attachments ) ) {
return; // No unattached images found in this batch
}
$deleted_items = array();
foreach ( $attachments as $attachment_id ) {
// Check if the attachment is an image (optional but good practice)
if ( ! wp_attachment_is_image( $attachment_id ) ) {
continue;
}
// If not in used list, delete
if ( ! in_array( $attachment_id, $used_image_ids, true ) ) {
$attachment_url = wp_get_attachment_url( $attachment_id );
$delete_result = wp_delete_attachment( $attachment_id, true ); // true = force delete
if ($delete_result) {
$deleted_items[] = array(
'id' => $attachment_id,
'url' => $attachment_url ? $attachment_url : 'URL not found',
);
}
}
}
if ( ! empty( $deleted_items ) ) {
$this->append_log( $deleted_items, $this->log_option_name, 'Deleted Image ID: %d (%s)' ); // Log the deleted images
}
}
/**
* Generic function to append deletion log to an option in the database
* @param array $items Array of items deleted (can be IDs or associative arrays)
* @param string $option_key The option name to store the log
* @param string $log_format Sprintf format string for the log entry (e.g., '%s - Deleted ID: %d')
*/
private function append_log( $items, $option_key, $log_format ) {
$logs = get_option( $option_key, array() );
$now = current_time( 'mysql' );
foreach ( $items as $item ) {
// Adjust based on whether $item is simple ID or array
if ( is_array($item) && isset($item['id']) ) {
$id = $item['id'];
$extra = isset($item['url']) ? $item['url'] : (isset($item['title']) ? $item['title'] : '');
$logs[] = sprintf( $log_format, $now, $id, $extra );
} elseif ( is_numeric($item) ) {
$logs[] = sprintf( $log_format, $now, $item );
} else {
// Handle unexpected item format if necessary
$logs[] = sprintf('%s - Logged item: %s', $now, print_r($item, true));
}
}
// Keep the log from growing indefinitely (optional)
$max_log_entries = 1000;
if (count($logs) > $max_log_entries) {
$logs = array_slice($logs, count($logs) - $max_log_entries);
}
update_option( $option_key, $logs );
}
/**
* Returns array of image IDs used by my-pro-id posts in the designated metafields
*/
private function get_used_image_ids() {
global $wpdb;
// Metafields to check (ensure this list is accurate for your setup)
$meta_keys = array(
'kid_image',
'kid_gallery',
'avatar1',
'cover-image',
'logocongty',
'gioi_thieu_cover',
'gallery',
'line_qr',
'viber_qr',
'zalo_qr_code',
'wechat_qr',
'bank-qr-1',
);
// Repeater fields / complex fields that might contain IDs
// ACF repeaters often store data serialized or with sub-field keys like 'repeater_field_0_sub_field'
// You might need more complex logic to extract IDs from these.
// Assuming simple storage for these examples:
$repeater_subfields = array(
'productsimage', // Example: Assuming this stores IDs directly or in a simple array
'505_profile_image', // Example: Assuming this stores IDs directly
);
// Combine simple and complex keys for the initial query
$all_meta_keys = array_merge( $meta_keys, $repeater_subfields );
// 1) Get all my-pro-id post IDs
$my_pro_id_posts = get_posts( array(
'post_type' => 'my-pro-id',
'post_status' => 'any',
'posts_per_page' => -1, // Get all
'fields' => 'ids',
) );
if ( empty( $my_pro_id_posts ) ) {
return array(); // No posts, so no used images
}
// 2) Gather all meta values for those posts and relevant keys
$placeholders = implode( ', ', array_fill( 0, count( $my_pro_id_posts ), '%d' ) );
$placeholders_meta_keys = implode( ', ', array_fill( 0, count( $all_meta_keys ), '%s' ) );
$sql = $wpdb->prepare( "
SELECT meta_value
FROM {$wpdb->postmeta}
WHERE post_id IN ($placeholders)
AND meta_key IN ($placeholders_meta_keys)
", array_merge( $my_pro_id_posts, $all_meta_keys ) );
$raw_meta_values = $wpdb->get_col( $sql );
// 3) Extract attachment IDs from various formats (simple ID, serialized array, etc.)
$used_image_ids = array();
foreach ( $raw_meta_values as $meta_value ) {
if ( empty($meta_value) ) continue;
// Try unserializing first
$maybe_array = maybe_unserialize( $meta_value );
if ( is_array( $maybe_array ) ) {
// Handle arrays (e.g., galleries, some repeater formats)
foreach ( $maybe_array as $inner_value ) {
if ( is_numeric( $inner_value ) && $inner_value > 0 ) {
$used_image_ids[] = (int) $inner_value;
}
// Add checks here for more complex structures if needed (e.g., array of arrays with 'id' key)
}
} elseif ( is_numeric( $meta_value ) && $meta_value > 0 ) {
// Handle simple numeric IDs
$used_image_ids[] = (int) $meta_value;
}
// Add more checks if IDs are stored in other formats (e.g., JSON strings)
}
// Add Featured Images (Post Thumbnails) for 'my-pro-id' posts
foreach ($my_pro_id_posts as $post_id) {
if (has_post_thumbnail($post_id)) {
$thumb_id = get_post_thumbnail_id($post_id);
if ($thumb_id) {
$used_image_ids[] = $thumb_id;
}
}
}
return array_unique( $used_image_ids ); // Return unique IDs
}
/**
* ADMIN SETTINGS PAGE
*/
public function add_settings_page() {
add_options_page(
'My Pro ID Cleanup', // Page Title
'My Pro ID Cleanup', // Menu Title
'manage_options', // Capability required
'my-pro-id-cleanup', // Menu Slug
array( $this, 'render_settings_page' ) // Callback function
);
}
/**
* Register setting & fields
*/
public function register_settings() {
register_setting( 'my_pro_id_cleanup_group', $this->option_name, array( $this, 'sanitize_settings' ) );
// Section for Auto-Cleanup Schedule
add_settings_section(
'my_pro_id_cleanup_section',
__( 'Auto-Cleanup Schedule (Unused Images)', 'my-pro-id' ),
null, // Callback for section description (optional)
'my-pro-id-cleanup' // Page slug
);
// Field: Batch size (images)
add_settings_field(
'batch_size',
__( 'Number of images to process per run', 'my-pro-id' ),
array( $this, 'field_batch_size_cb' ),
'my-pro-id-cleanup',
'my_pro_id_cleanup_section'
);
// Field: Schedule Frequency
add_settings_field(
'schedule',
__( 'Cleanup Frequency', 'my-pro-id' ),
array( $this, 'field_schedule_cb' ),
'my-pro-id-cleanup',
'my_pro_id_cleanup_section'
);
// Field: Time of Day
add_settings_field(
'time',
__( 'Hour to Run (0-23)', 'my-pro-id' ),
array( $this, 'field_time_cb' ),
'my-pro-id-cleanup',
'my_pro_id_cleanup_section'
);
}
/**
* Sanitize settings input before saving. Reschedule cron on save.
*/
public function sanitize_settings( $input ) {
$sanitized = array();
// Sanitize Batch Size
$sanitized['batch_size'] = isset( $input['batch_size'] ) ? absint( $input['batch_size'] ) : 500;
if ($sanitized['batch_size'] < 1) $sanitized['batch_size'] = 500; // Ensure minimum value
// Sanitize Schedule
$possible_schedules = array( 'daily', 'weekly', 'monthly' );
$sanitized['schedule'] = isset( $input['schedule'] ) && in_array( $input['schedule'], $possible_schedules, true )
? $input['schedule']
: 'daily'; // Default to daily
// Sanitize Time
$sanitized['time'] = ( isset( $input['time'] ) && is_numeric( $input['time'] ) )
? min( 23, max( 0, intval( $input['time'] ) ) ) // Clamp between 0 and 23
: 2; // Default to 2 AM
// Reschedule cron event whenever settings are updated
$this->schedule_cron_job();
return $sanitized;
}
// Callback functions to render the settings fields
public function field_batch_size_cb() {
$options = get_option( $this->option_name );
$batch_size = ! empty( $options['batch_size'] ) ? $options['batch_size'] : 500;
echo '<input type="number" name="' . esc_attr($this->option_name) . '[batch_size]" value="' . esc_attr( $batch_size ) . '" min="1" step="1" />';
echo '<p class="description">' . __('Number of unattached images to check and potentially delete during each scheduled run.', 'my-pro-id') . '</p>';
}
public function field_schedule_cb() {
$options = get_option( $this->option_name );
$schedule = ! empty( $options['schedule'] ) ? $options['schedule'] : 'daily';
echo '<select name="' . esc_attr($this->option_name) . '[schedule]">';
echo '<option value="daily" ' . selected( $schedule, 'daily', false ) . '>Daily</option>';
echo '<option value="weekly" ' . selected( $schedule, 'weekly', false ) . '>Weekly</option>';
echo '<option value="monthly" ' . selected( $schedule, 'monthly', false ) . '>Monthly</option>';
echo '</select>';
echo '<p class="description">' . __('How often the automatic image cleanup should run.', 'my-pro-id') . '</p>';
}
public function field_time_cb() {
$options = get_option( $this->option_name );
$time = isset( $options['time'] ) ? $options['time'] : 2;
echo '<input type="number" name="' . esc_attr($this->option_name) . '[time]" value="' . esc_attr( $time ) . '" min="0" max="23" step="1" />';
echo '<p class="description">' . __('The hour of the day (24-hour format) the cleanup should start.', 'my-pro-id') . '</p>';
}
/**
* Render the settings page content
*/
public function render_settings_page() {
// Check user capability
if ( ! current_user_can( 'manage_options' ) ) {
wp_die(__( 'You do not have sufficient permissions to access this page.' ));
}
//--- PROCESS MANUAL ACTIONS FIRST ---
$message = ''; // Store feedback messages
//--- MANUAL IMAGE CLEANUP ---
if ( isset( $_POST['my_pro_id_manual_cleanup'] ) && check_admin_referer( 'my_pro_id_manual_cleanup_action' ) ) {
$limit = isset( $_POST['manual_limit'] ) ? absint( $_POST['manual_limit'] ) : 10;
if ($limit < 1) $limit = 10;
$this->delete_unattached_images( $limit ); // Re-use the batch deletion logic
$message .= '<div id="message" class="updated notice is-dismissible"><p>' . __('Manual image cleanup executed. Check logs below.', 'my-pro-id') . '</p></div>';
}
//--- MANUAL POST CLEANUP (EMPTY my-pro-id) ---
if ( isset( $_POST['my_pro_id_post_cleanup'] ) && check_admin_referer( 'my_pro_id_post_cleanup_action' ) ) {
$post_limit = isset( $_POST['post_limit'] ) ? absint( $_POST['post_limit'] ) : 100;
if ($post_limit < 1) $post_limit = 100;
$deleted_count = $this->delete_empty_my_pro_id_posts( $post_limit );
if ($deleted_count > 0) {
$message .= '<div id="message" class="updated notice is-dismissible"><p>' . sprintf(
__( 'My Pro ID post (empty image) cleanup complete. %d posts were deleted. Check logs below.', 'my-pro-id' ),
$deleted_count
) . '</p></div>';
} else {
$message .= '<div id="message" class="notice notice-info is-dismissible"><p>' . __( 'No empty (image) My Pro ID posts found to delete.', 'my-pro-id' ) . '</p></div>';
}
}
//--- MANUAL NORMAL POST CLEANUP (NON-ADMIN) ---
if ( isset( $_POST['my_pro_id_manual_normal_post_cleanup'] ) && check_admin_referer( 'my_pro_id_normal_post_cleanup_action' ) ) {
$batch_size = isset( $_POST['normal_post_batch_size'] ) ? absint( $_POST['normal_post_batch_size'] ) : 100;
if ($batch_size < 1) $batch_size = 100;
$deleted_count = $this->delete_normal_posts_in_batch( $batch_size ); // Call the non-admin deletion function
if ( $deleted_count > 0 ) {
$message .= '<div id="message" class="updated notice is-dismissible"><p>' . sprintf(
__( 'Normal post (non-admin) cleanup complete for this batch. %d posts were deleted. Run again to delete more. Check logs below.', 'my-pro-id' ),
$deleted_count
) . '</p></div>';
} else {
$message .= '<div id="message" class="notice notice-info is-dismissible"><p>' . __( 'No more non-admin normal posts found to delete in this batch.', 'my-pro-id' ) . '</p></div>';
}
}
// --- NEW: MANUAL ALL NORMAL POST CLEANUP ---
if ( isset( $_POST['my_pro_id_manual_all_normal_post_cleanup'] ) && check_admin_referer( 'my_pro_id_all_normal_post_cleanup_action' ) ) {
$batch_size = isset( $_POST['all_normal_post_batch_size'] ) ? absint( $_POST['all_normal_post_batch_size'] ) : 100;
if ($batch_size < 1) $batch_size = 100;
$deleted_count = $this->delete_all_normal_posts_in_batch( $batch_size ); // Call the new deletion function
if ( $deleted_count > 0 ) {
$message .= '<div id="message" class="updated notice is-dismissible"><p>' . sprintf(
__( 'ALL normal post cleanup complete for this batch. %d posts (including admin posts) were deleted. Run again to delete more. Check logs below.', 'my-pro-id' ),
$deleted_count
) . '</p></div>';
} else {
$message .= '<div id="message" class="notice notice-info is-dismissible"><p>' . __( 'No more normal posts found to delete in this batch.', 'my-pro-id' ) . '</p></div>';
}
}
// --- NEW: MANUAL MY PRO ID POSTS WITHOUT PHONE CLEANUP ---
if ( isset( $_POST['my_pro_id_no_phone_post_cleanup'] ) && check_admin_referer( 'my_pro_id_no_phone_post_cleanup_action' ) ) {
$batch_size = isset( $_POST['no_phone_post_batch_size'] ) ? absint( $_POST['no_phone_post_batch_size'] ) : 100;
if ($batch_size < 1) $batch_size = 100;
$deleted_count = $this->delete_my_pro_id_posts_without_phone( $batch_size ); // Call the new deletion function
if ( $deleted_count > 0 ) {
$message .= '<div id="message" class="updated notice is-dismissible"><p>' . sprintf(
__( 'My Pro ID posts (no phone) cleanup complete for this batch. %d posts were deleted. Run again to delete more. Check logs below.', 'my-pro-id' ),
$deleted_count
) . '</p></div>';
} else {
$message .= '<div id="message" class="notice notice-info is-dismissible"><p>' . __( 'No My Pro ID posts without a phone number found to delete in this batch.', 'my-pro-id' ) . '</p></div>';
}
}
//--- DANGER ZONE ACTIONS ---
$correct_password = 'iLoveProID@123'; // Example password - CHANGE THIS or use a better method!
//--- DELETE ALL EXCEPT ADMIN ---
if ( isset( $_POST['my_pro_id_delete_all_except_admin'] ) && check_admin_referer( 'my_pro_id_delete_all_except_admin_action' ) ) {
$password_submitted = isset( $_POST['confirmation_password_1'] ) ? sanitize_text_field( $_POST['confirmation_password_1'] ) : '';
if ( ! hash_equals( $correct_password, $password_submitted ) ) { // Use hash_equals for timing attack resistance
$message .= '<div id="message" class="error notice is-dismissible"><p><strong>' . __('Error:', 'my-pro-id') . '</strong> ' . __('Incorrect password for "Delete All Except Admin". Deletion aborted.', 'my-pro-id') . '</p></div>';
} else {
// Password matches, proceed with deletion
$deleted_all_count = $this->delete_all_posts_and_media_except_admin();
$message .= '<div id="message" class="updated notice is-dismissible"><p>' . sprintf(
__('"Delete All Except Admin" complete. %d non-admin posts/media items were removed.', 'my-pro-id'),
intval( $deleted_all_count )
) . '</p></div>';
}
}
// --- NEW: DELETE ALL POSTS & NON-ADMIN MEDIA ---
if ( isset( $_POST['my_pro_id_delete_all_posts_non_admin_media'] ) && check_admin_referer( 'my_pro_id_delete_all_posts_non_admin_media_action' ) ) {
$password_submitted = isset( $_POST['confirmation_password_2'] ) ? sanitize_text_field( $_POST['confirmation_password_2'] ) : '';
if ( ! hash_equals( $correct_password, $password_submitted ) ) {
$message .= '<div id="message" class="error notice is-dismissible"><p><strong>' . __('Error:', 'my-pro-id') . '</strong> ' . __('Incorrect password for "Delete All Posts & Non-Admin Media". Deletion aborted.', 'my-pro-id') . '</p></div>';
} else {
// Password matches, proceed with deletion
$deleted_count = $this->delete_all_posts_and_non_admin_media(); // Call the new function
$message .= '<div id="message" class="updated notice is-dismissible"><p>' . sprintf(
__('"Delete All Posts & Non-Admin Media" complete. %d items (all posts + non-admin media) were removed. Check logs below.', 'my-pro-id'),
intval( $deleted_count )
) . '</p></div>';
}
}
//--- RENDER THE PAGE CONTENT ---
?>
<div class="wrap">
<h1><?php esc_html_e( 'My Pro ID Cleanup Settings', 'my-pro-id' ); ?></h1>
<?php echo $message; // Display feedback messages at the top ?>
<form method="post" action="options.php">
<?php
settings_fields( 'my_pro_id_cleanup_group' ); // Nonce, action, option_page fields
do_settings_sections( 'my-pro-id-cleanup' ); // Renders the sections and fields
submit_button( __( 'Save Auto-Cleanup Settings', 'my-pro-id' ) );
?>
</form>
<hr />
<h2><?php esc_html_e( 'Manual Cleanup Actions', 'my-pro-id' ); ?></h2>
<div style="margin-bottom: 20px; padding: 10px; border: 1px solid #ccc; border-radius: 5px;">
<h3><?php esc_html_e( 'Unused Image Cleanup', 'my-pro-id' ); ?></h3>
<p><?php esc_html_e( 'Manually delete a batch of images that are not attached to any post/page and not found in the specified \'my-pro-id\' fields.', 'my-pro-id' ); ?></p>
<form method="post" action=""> <?php // Post to the current page ?>
<?php wp_nonce_field( 'my_pro_id_manual_cleanup_action' ); ?>
<label for="manual_limit"><?php esc_html_e( 'Images to process:', 'my-pro-id' ); ?></label>
<input type="number" id="manual_limit" name="manual_limit" value="50" min="1" step="1" style="width: 80px;" />
<button type="submit" name="my_pro_id_manual_cleanup" class="button button-secondary">
<?php esc_html_e( 'Delete Unused Image Batch', 'my-pro-id' ); ?>
</button>
<p class="description"><?php esc_html_e( 'Run this multiple times if you have many images to clean up.', 'my-pro-id' ); ?></p>
</form>
</div>
<div style="margin-bottom: 20px; padding: 10px; border: 1px solid #ccc; border-radius: 5px;">
<h3><?php esc_html_e( 'Delete My Pro ID Posts without Images', 'my-pro-id' ); ?></h3>
<p><?php esc_html_e( 'Deletes a batch of \'my-pro-id\' posts that do not have any images in the specified meta fields.', 'my-pro-id' ); ?></p>
<form method="post" action="">
<?php wp_nonce_field( 'my_pro_id_post_cleanup_action' ); ?>
<label for="post_limit"><?php esc_html_e( 'Posts to delete:', 'my-pro-id' ); ?></label>
<input type="number" id="post_limit" name="post_limit" value="100" min="1" step="1" style="width: 80px;" />
<button type="submit" name="my_pro_id_post_cleanup" class="button button-secondary">
<?php esc_html_e( 'Delete Empty (Image) My Pro ID Post Batch', 'my-pro-id' ); ?>
</button>
<p class="description"><?php esc_html_e( 'Run this multiple times if needed.', 'my-pro-id' ); ?></p>
</form>
</div>
<div style="margin-bottom: 20px; padding: 10px; border: 1px solid #ccc; border-radius: 5px;">
<h3><?php esc_html_e( 'Delete My Pro ID Posts without Phone Number', 'my-pro-id' ); ?></h3>
<p><?php esc_html_e( 'Deletes a batch of \'my-pro-id\' posts that do not have a value in the \'phone\' meta field.', 'my-pro-id' ); ?></p>
<form method="post" action="">
<?php wp_nonce_field( 'my_pro_id_no_phone_post_cleanup_action' ); ?>
<label for="no_phone_post_batch_size"><?php esc_html_e( 'Posts to delete:', 'my-pro-id' ); ?></label>
<input type="number" id="no_phone_post_batch_size" name="no_phone_post_batch_size" value="100" min="1" step="1" style="width: 80px;" />
<button type="submit" name="my_pro_id_no_phone_post_cleanup" class="button button-secondary">
<?php esc_html_e( 'Delete No-Phone My Pro ID Post Batch', 'my-pro-id' ); ?>
</button>
<p class="description"><?php esc_html_e( 'Run this multiple times if needed.', 'my-pro-id' ); ?></p>
</form>
</div>
<div style="margin-bottom: 20px; padding: 10px; border: 1px solid #ccc; border-radius: 5px;">
<h3><?php esc_html_e( 'Delete Normal Posts (Non-Admin Authors)', 'my-pro-id' ); ?></h3>
<p><?php esc_html_e( 'Delete standard WordPress posts (type \'post\') in batches. Posts authored by administrators will NOT be deleted.', 'my-pro-id' ); ?></p>
<form method="post" action="">
<?php wp_nonce_field( 'my_pro_id_normal_post_cleanup_action' ); // Add a nonce field ?>
<label for="normal_post_batch_size"><?php esc_html_e( 'Posts to delete:', 'my-pro-id' ); ?></label>
<input type="number" id="normal_post_batch_size" name="normal_post_batch_size" value="100" min="1" step="1" style="width: 80px;" />
<button type="submit" name="my_pro_id_manual_normal_post_cleanup" class="button button-secondary">
<?php esc_html_e( 'Delete Non-Admin Normal Posts Batch', 'my-pro-id' ); ?>
</button>
<p class="description"><?php esc_html_e( 'Run this multiple times to delete more posts. This permanently deletes posts (skips trash).', 'my-pro-id' ); ?></p>
</form>
</div>
<div style="margin-bottom: 20px; padding: 10px; border: 1px solid #ccc; border-radius: 5px;">
<h3><?php esc_html_e( 'Delete ALL Normal Posts (Including Admins)', 'my-pro-id' ); ?></h3>
<p><strong><?php esc_html_e( 'Warning:', 'my-pro-id' ); ?></strong> <?php esc_html_e( 'This will delete ALL standard WordPress posts (type \'post\'), including those created by administrators. Use with caution.', 'my-pro-id' ); ?></p>
<form method="post" action="">
<?php wp_nonce_field( 'my_pro_id_all_normal_post_cleanup_action' ); ?>
<label for="all_normal_post_batch_size"><?php esc_html_e( 'Posts to delete:', 'my-pro-id' ); ?></label>
<input type="number" id="all_normal_post_batch_size" name="all_normal_post_batch_size" value="100" min="1" step="1" style="width: 80px;" />
<button type="submit" name="my_pro_id_manual_all_normal_post_cleanup" class="button button-primary" onclick="return confirm('<?php esc_attr_e( 'Are you sure you want to delete ALL normal posts, including those by administrators?', 'my-pro-id' ); ?>');">
<?php esc_html_e( 'Delete ALL Normal Posts Batch', 'my-pro-id' ); ?>
</button>
<p class="description"><?php esc_html_e( 'Run this multiple times to delete more posts. This permanently deletes posts (skips trash).', 'my-pro-id' ); ?></p>
</form>
</div>
<hr />
<div style="border: 2px solid red; padding: 15px; margin-top: 20px; border-radius: 5px;">
<h2 style="color:red; margin-top: 0;"><?php esc_html_e( 'Danger Zone: Bulk Deletion', 'my-pro-id' ); ?></h2>
<p><strong><?php esc_html_e( 'EXTREME WARNING:', 'my-pro-id' ); ?></strong> <?php esc_html_e( 'Actions in this section are highly destructive and cannot be undone. Make a full backup before proceeding!', 'my-pro-id' ); ?></p>
<p><?php esc_html_e( 'Password for confirmation:', 'my-pro-id' ); ?> <code><?php echo esc_html($correct_password); ?></code> <?php esc_html_e( '(Strongly recommend changing this in the plugin code!)', 'my-pro-id' ); ?></p>
<div style="margin-bottom: 20px; padding: 10px; border: 1px solid darkred; border-radius: 5px;">
<h3 style="color:darkred;"><?php esc_html_e( 'Option 1: Delete All Posts & Media (Except Admin Authorship)', 'my-pro-id' ); ?></h3>
<p><?php esc_html_e( 'This will permanently delete ALL posts, pages, custom post types, and media library items that were NOT authored by users with the Administrator role.', 'my-pro-id' ); ?></p>
<form method="post" action="">
<?php wp_nonce_field( 'my_pro_id_delete_all_except_admin_action' ); ?>
<p>
<label for="confirmation_password_1">
<strong><?php esc_html_e( 'Enter Password to Confirm:', 'my-pro-id' ); ?></strong>
</label><br />
<input type="password" name="confirmation_password_1" id="confirmation_password_1" required style="border-color: red;" />
</p>
<button type="submit" name="my_pro_id_delete_all_except_admin" class="button button-danger" style="background-color:red; color:#fff; border-color: darkred;" onclick="return confirm('<?php esc_attr_e( 'ARE YOU ABSOLUTELY SURE? This will delete all NON-ADMIN content permanently. There is NO UNDO. Click OK only if you have a backup and understand the consequences.', 'my-pro-id' ); ?>');">
<?php esc_html_e( 'DELETE ALL NON-ADMIN CONTENT NOW', 'my-pro-id' ); ?>
</button>
</form>
</div>
<div style="padding: 10px; border: 1px solid darkred; border-radius: 5px;">
<h3 style="color:darkred;"><?php esc_html_e( 'Option 2: Delete ALL Posts (Any Author) & Non-Admin Media', 'my-pro-id' ); ?></h3>
<p><?php esc_html_e( 'This will permanently delete ALL posts, pages, and custom post types (REGARDLESS OF AUTHOR) and ALL media library items that were NOT authored by administrators.', 'my-pro-id' ); ?></p>
<form method="post" action="">
<?php wp_nonce_field( 'my_pro_id_delete_all_posts_non_admin_media_action' ); ?>
<p>
<label for="confirmation_password_2">
<strong><?php esc_html_e( 'Enter Password to Confirm:', 'my-pro-id' ); ?></strong>
</label><br />
<input type="password" name="confirmation_password_2" id="confirmation_password_2" required style="border-color: red;" />
</p>
<button type="submit" name="my_pro_id_delete_all_posts_non_admin_media" class="button button-danger" style="background-color:red; color:#fff; border-color: darkred;" onclick="return confirm('<?php esc_attr_e( 'ARE YOU ABSOLUTELY SURE? This will delete ALL posts (including admin posts) and all NON-ADMIN media permanently. There is NO UNDO. Click OK only if you have a backup and understand the consequences.', 'my-pro-id' ); ?>');">
<?php esc_html_e( 'DELETE ALL POSTS & NON-ADMIN MEDIA NOW', 'my-pro-id' ); ?>
</button>
</form>
</div>
</div>
<hr style="margin-top: 30px;" />
<h2><?php esc_html_e( 'Activity Logs', 'my-pro-id' ); ?></h2>
<p><?php esc_html_e( 'Showing the most recent 20 entries for each log type.', 'my-pro-id' ); ?></p>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px;">
<?php
// Helper function to display logs
function display_log_section($title, $log_option, $empty_message) {
echo '<div style="border: 1px solid #eee; padding: 10px; border-radius: 4px;">';
echo '<h3>' . esc_html($title) . '</h3>';
$logs = get_option( $log_option, array() );
if ( ! empty( $logs ) ) {
$logs = array_reverse( $logs ); // Newest first
$logs = array_slice( $logs, 0, 20 );
echo '<ul style="max-height: 200px; overflow-y: auto; border: 1px solid #ccc; padding: 10px; list-style: disc; margin-left: 20px; font-size: 0.9em;">';
foreach ( $logs as $entry ) {
echo '<li>' . esc_html( $entry ) . '</li>';
}
echo '</ul>';
} else {
echo '<p>' . esc_html( $empty_message ) . '</p>';
}
echo '</div>';
}
// Display existing and new logs
display_log_section(__( 'Deleted Images Log', 'my-pro-id' ), $this->log_option_name, __( 'No images have been deleted yet via this tool.', 'my-pro-id' ));
display_log_section(__( 'Deleted My Pro ID Posts (Empty Image) Log', 'my-pro-id' ), $this->post_log_option, __( 'No empty (image) my-pro-id posts have been deleted yet.', 'my-pro-id' ));
display_log_section(__( 'Deleted My Pro ID Posts (No Phone) Log', 'my-pro-id' ), $this->no_phone_post_log_option, __( 'No my-pro-id posts without phone numbers have been deleted yet.', 'my-pro-id' )); // NEW
display_log_section(__( 'Deleted Normal Posts (Non-Admin) Log', 'my-pro-id' ), $this->normal_post_log_option, __( 'No normal posts (non-admin) have been deleted via this tool yet.', 'my-pro-id' ));
display_log_section(__( 'Deleted ALL Normal Posts Log', 'my-pro-id' ), $this->all_normal_post_log_option, __( 'No normal posts (including admin) have been deleted via this tool yet.', 'my-pro-id' )); // NEW
display_log_section(__( 'Bulk Delete: All Posts & Non-Admin Media Log', 'my-pro-id' ), $this->all_posts_non_admin_media_log_option, __( 'The "Delete All Posts & Non-Admin Media" action has not been run yet.', 'my-pro-id' )); // NEW
// Note: No specific log for "Delete All Except Admin" shown here, assumes it's too broad or covered by other logs implicitly. Add if needed.
?>
</div>
</div><?php
}
/**
* Find and delete empty my-pro-id posts up to a limit.
* This version processes posts in batches within the function call until the limit is reached.
*
* @param int $max_delete Maximum total number of posts to delete in this run.
* @return int The number of posts actually deleted.
*/
private function delete_empty_my_pro_id_posts( $max_delete ) {
$deleted_count = 0;
$processed_ids = array(); // Keep track of posts checked in this run to avoid infinite loops if query is slow
$batch_size = 500; // Process in batches of 500 to avoid memory issues
while ($deleted_count < $max_delete) {
$remaining_to_find = $max_delete - $deleted_count;
$limit_for_query = min($batch_size, $remaining_to_find);
$empty_post_ids_batch = $this->get_empty_my_pro_id_posts( $limit_for_query, $processed_ids );
if ( empty( $empty_post_ids_batch ) ) {
break; // No more empty posts found in this iteration
}
$deleted_in_batch = 0;
$ids_to_log = array();
foreach ( $empty_post_ids_batch as $pid ) {
if ($deleted_count >= $max_delete) break; // Stop if we reached the overall limit
$delete_result = wp_delete_post( $pid, true ); // true = force delete
if ( $delete_result !== false && $delete_result !== null ) {
$deleted_count++;
$deleted_in_batch++;
$ids_to_log[] = $pid;
$processed_ids[] = $pid; // Add to processed list
} else {
// Deletion failed, maybe log this?
$processed_ids[] = $pid; // Add to processed even if failed to avoid retrying immediately
}
}
// Log the posts deleted in this batch
if ( !empty($ids_to_log) ) {
$this->append_log( $ids_to_log, $this->post_log_option, '%s - Deleted empty (image) my-pro-id post #%d' );
}
// Safety break if no posts were deleted in a batch where posts were found
if ($deleted_in_batch == 0 && !empty($empty_post_ids_batch)) {
// This might indicate an issue with deletion permissions or the posts themselves
break;
}
} // end while loop
return $deleted_count;
}
/**
* Return up to $limit my-pro-id post IDs that have NO associated images in specified fields.
*
* @param int $limit Max number of IDs to return.
* @param array $exclude_ids Post IDs to exclude from the search (already processed).
* @return array Array of post IDs.
*/
private function get_empty_my_pro_id_posts( $limit, $exclude_ids = array() ) {
global $wpdb;
// Metafields where images might be stored (ensure this list is accurate)
$meta_keys = array(
'kid_image', 'kid_gallery', 'avatar1', 'cover-image', 'logocongty',
'gioi_thieu_cover', 'gallery', 'line_qr', 'viber_qr', 'zalo_qr_code',
'wechat_qr', 'bank-qr-1', 'productsimage', '505_profile_image',
'_thumbnail_id' // Also check the standard featured image meta key
);
// 1. Query all relevant my-pro-id post IDs, excluding those already processed
$query_args = array(
'post_type' => 'my-pro-id',
'post_status' => 'any',
'posts_per_page' => -1, // Check all initially
'fields' => 'ids',
'orderby' => 'ID',
'order' => 'ASC', // Process older posts first
);
if (!empty($exclude_ids)) {
$query_args['post__not_in'] = $exclude_ids;
}
$all_relevant_posts = get_posts( $query_args );
if ( empty( $all_relevant_posts ) ) {
return array(); // No more posts to check
}
// 2. Find posts that HAVE any value in the specified meta keys OR a featured image
$posts_with_potential_images = array();
// Chunk the post IDs to avoid massive SQL queries if there are many posts
$post_chunks = array_chunk($all_relevant_posts, 500);
$placeholders_meta_keys = implode( ', ', array_fill( 0, count( $meta_keys ), '%s' ) );
foreach ($post_chunks as $post_chunk) {
$placeholders_for_posts = implode( ', ', array_fill( 0, count( $post_chunk ), '%d' ) );
$sql = $wpdb->prepare("
SELECT DISTINCT post_id
FROM {$wpdb->postmeta}
WHERE meta_key IN ($placeholders_meta_keys)
AND post_id IN ($placeholders_for_posts)
AND meta_value IS NOT NULL AND meta_value != '' AND meta_value != 'a:0:{}' -- Check for empty serialized arrays too
", array_merge( $meta_keys, $post_chunk ));
$posts_with_meta = $wpdb->get_col( $sql );
if (!empty($posts_with_meta)) {
$posts_with_potential_images = array_merge($posts_with_potential_images, $posts_with_meta);
}
}
// Convert to integers and ensure uniqueness
$posts_with_potential_images = array_map( 'intval', $posts_with_potential_images );
$posts_with_potential_images = array_unique($posts_with_potential_images);
// 3. "Empty" posts are those in $all_relevant_posts but NOT in $posts_with_potential_images
$empty_posts = array_diff( $all_relevant_posts, $posts_with_potential_images );
// 4. Return only up to the specified limit
$empty_posts = array_slice( $empty_posts, 0, $limit );
return $empty_posts;
}
/**
* NEW: Find and delete 'my-pro-id' posts without a 'phone' meta value.
*
* @param int $max_delete Maximum total number of posts to delete in this run.
* @return int The number of posts actually deleted.
*/
private function delete_my_pro_id_posts_without_phone( $max_delete ) {
$deleted_count = 0;
$processed_ids = array();
$batch_size = 500;
while ($deleted_count < $max_delete) {
$remaining_to_find = $max_delete - $deleted_count;
$limit_for_query = min($batch_size, $remaining_to_find);
$no_phone_post_ids_batch = $this->get_my_pro_id_posts_without_phone( $limit_for_query, $processed_ids );
if ( empty( $no_phone_post_ids_batch ) ) {
break; // No more posts without phone found
}
$deleted_in_batch = 0;
$ids_to_log = array();
foreach ( $no_phone_post_ids_batch as $pid ) {
if ($deleted_count >= $max_delete) break;
$delete_result = wp_delete_post( $pid, true ); // force delete
if ( $delete_result !== false && $delete_result !== null ) {
$deleted_count++;
$deleted_in_batch++;
$ids_to_log[] = $pid;
$processed_ids[] = $pid;
} else {
$processed_ids[] = $pid; // Add to processed even if failed
}
}
// Log the deleted posts
if ( !empty($ids_to_log) ) {
$this->append_log( $ids_to_log, $this->no_phone_post_log_option, '%s - Deleted my-pro-id post (no phone) #%d' );
}
if ($deleted_in_batch == 0 && !empty($no_phone_post_ids_batch)) {
break; // Safety break
}
} // end while
return $deleted_count;
}
/**
* NEW: Return up to $limit my-pro-id post IDs that do NOT have a 'phone' meta field or where it's empty.
*
* @param int $limit Max number of IDs to return.
* @param array $exclude_ids Post IDs to exclude from the search.
* @return array Array of post IDs.
*/
private function get_my_pro_id_posts_without_phone( $limit, $exclude_ids = array() ) {
global $wpdb;
$phone_meta_key = 'phone'; // The meta key for the phone number
// 1. Query all relevant my-pro-id post IDs, excluding processed ones
$query_args = array(
'post_type' => 'my-pro-id',
'post_status' => 'any',
'posts_per_page' => -1,
'fields' => 'ids',
'orderby' => 'ID',
'order' => 'ASC',
);
if (!empty($exclude_ids)) {
$query_args['post__not_in'] = $exclude_ids;
}
$all_relevant_posts = get_posts( $query_args );
if ( empty( $all_relevant_posts ) ) {
return array();
}
// 2. Find posts that HAVE a non-empty 'phone' meta value
$posts_with_phone = array();
$post_chunks = array_chunk($all_relevant_posts, 500);
foreach ($post_chunks as $post_chunk) {
$placeholders_for_posts = implode( ', ', array_fill( 0, count( $post_chunk ), '%d' ) );
$sql = $wpdb->prepare("
SELECT DISTINCT post_id
FROM {$wpdb->postmeta}
WHERE meta_key = %s
AND post_id IN ($placeholders_for_posts)
AND meta_value IS NOT NULL AND meta_value != ''
", array_merge( array($phone_meta_key), $post_chunk ));
$posts_found = $wpdb->get_col( $sql );
if (!empty($posts_found)) {
$posts_with_phone = array_merge($posts_with_phone, $posts_found);
}
}
$posts_with_phone = array_map( 'intval', $posts_with_phone );
$posts_with_phone = array_unique($posts_with_phone);
// 3. Posts without phone are those in $all_relevant_posts but NOT in $posts_with_phone
$posts_without_phone = array_diff( $all_relevant_posts, $posts_with_phone );
// 4. Return up to the limit
$posts_without_phone = array_slice( $posts_without_phone, 0, $limit );
return $posts_without_phone;
}
/**
* Delete standard WordPress posts ('post' type) in a batch, excluding admin posts.
*
* @param int $batch_size The maximum number of posts to delete in this batch.
* @return int The number of posts actually deleted in this batch.
*/
private function delete_normal_posts_in_batch( $batch_size ) {
// 1. Get admin user IDs to exclude their posts
$admin_users = get_users( array( 'role__in' => ['administrator'], 'fields' => 'ID' ) ); // More specific role query
$admin_ids = !empty($admin_users) ? $admin_users : array(1); // Default to exclude user 1 if no admins found (edge case)
// 2. Query for posts to delete
$query_args = array(
'post_type' => 'post', // Target only standard posts
'post_status' => 'any', // Consider all statuses (publish, draft, trash, etc.)
'posts_per_page' => $batch_size, // Limit to the batch size
'post_author__not_in' => $admin_ids, // Exclude posts by admins
'fields' => 'ids', // Only get post IDs for efficiency
'orderby' => 'ID', // Order consistently
'order' => 'ASC' // Delete older posts first
);
$posts_to_delete_ids = get_posts( $query_args );
if ( empty( $posts_to_delete_ids ) ) {
return 0; // No posts found to delete in this batch
}
// 3. Delete the posts
$deleted_count = 0;
$deleted_ids_log = array();
foreach ( $posts_to_delete_ids as $post_id ) {
$delete_result = wp_delete_post( $post_id, true ); // true = force delete, bypass trash
if ( $delete_result !== false && $delete_result !== null ) { // Check if deletion was successful
$deleted_count++;
$deleted_ids_log[] = $post_id;
}
// Optional: Log failures?
}
// 4. Log the deleted posts (optional but recommended)
if ( $deleted_count > 0 ) {
$this->append_log( $deleted_ids_log, $this->normal_post_log_option, '%s - Deleted normal post (non-admin) #%d' );
}
return $deleted_count;
}
/**
* NEW: Delete ALL standard WordPress posts ('post' type) in a batch, including admin posts.
*
* @param int $batch_size The maximum number of posts to delete in this batch.
* @return int The number of posts actually deleted in this batch.
*/
private function delete_all_normal_posts_in_batch( $batch_size ) {
// 1. Query for posts to delete (no author exclusion)
$query_args = array(
'post_type' => 'post', // Target only standard posts
'post_status' => 'any', // Consider all statuses
'posts_per_page' => $batch_size, // Limit to the batch size
// 'post_author__not_in' => $admin_ids, // NO exclusion for this function
'fields' => 'ids', // Only get post IDs
'orderby' => 'ID', // Order consistently
'order' => 'ASC' // Delete older posts first
);
$posts_to_delete_ids = get_posts( $query_args );
if ( empty( $posts_to_delete_ids ) ) {
return 0; // No posts found to delete
}
// 2. Delete the posts
$deleted_count = 0;
$deleted_ids_log = array();
foreach ( $posts_to_delete_ids as $post_id ) {
$delete_result = wp_delete_post( $post_id, true ); // force delete
if ( $delete_result !== false && $delete_result !== null ) {
$deleted_count++;
$deleted_ids_log[] = $post_id;
}
}
// 3. Log the deleted posts
if ( $deleted_count > 0 ) {
$this->append_log( $deleted_ids_log, $this->all_normal_post_log_option, '%s - Deleted normal post (all authors) #%d' );
}
return $deleted_count;
}
/**
* Delete all posts and media *except* those authored by admins.
* Returns the total number of items deleted.
* USE WITH EXTREME CAUTION.
*/
private function delete_all_posts_and_media_except_admin() {
// 1. Get admin user IDs
$admin_users = get_users( array( 'role__in' => ['administrator'], 'fields' => 'ID' ) );
$admin_ids = !empty($admin_users) ? $admin_users : array(1); // Default exclude user 1
// 2. Query posts/pages/custom types (excluding attachments for now) not by admins
$post_types = get_post_types( array('public' => true, 'show_ui' => true), 'names' ); // Get CPTs too
unset($post_types['attachment']); // Handle attachments separately
$deleted_count = 0;
$batch_size = 200; // Process in batches
foreach ($post_types as $pt) {
while(true) {
$args = array(
'post_type' => $pt,
'post_status' => 'any',
'post_author__not_in' => $admin_ids, // Exclude admin posts
'posts_per_page' => $batch_size,
// 'offset' => $offset, // Offset not needed when deleting
'fields' => 'ids',
'orderby' => 'ID',
'order' => 'ASC',
'suppress_filters' => true, // Improve performance slightly
'ignore_sticky_posts' => true,
);
$posts_in_batch = get_posts($args);
if (empty($posts_in_batch)) {
break; // No more posts of this type found
}
$batch_deleted_ids = [];
foreach ($posts_in_batch as $post_id) {
$delete_result = wp_delete_post( $post_id, true ); // Force delete
if ($delete_result) {
$deleted_count++;
$batch_deleted_ids[] = $post_id;
}
}
// Optional: Log batch deletions here if needed, but this function doesn't have its own log by default
// If fewer posts were found than the batch size, we're done with this post type
if (count($posts_in_batch) < $batch_size) {
break;
}
// Add a small delay to prevent server overload (optional)
// sleep(1);
} // end while loop for batches
} // end foreach post_type
// 3. Query and delete attachments not authored by admins
while(true) {
$args = array(
'post_type' => 'attachment',
'post_status' => 'any', // Usually 'inherit' but 'any' is safer
'post_author__not_in' => $admin_ids, // Exclude admin media
'posts_per_page' => $batch_size,
// 'offset' => $offset, // Offset not needed
'fields' => 'ids',
'orderby' => 'ID',
'order' => 'ASC',
'suppress_filters' => true,
);
$attachments_in_batch = get_posts($args);
if (empty($attachments_in_batch)) {
break; // No more attachments found
}
$batch_deleted_ids = [];
foreach ($attachments_in_batch as $att_id) {
$delete_result = wp_delete_attachment( $att_id, true ); // Force delete attachment
if ($delete_result) {
$deleted_count++;
$batch_deleted_ids[] = $att_id;
}
}
// Optional: Log batch deletions here
if (count($attachments_in_batch) < $batch_size) {
break;
}
// sleep(1); // Optional delay
}
return $deleted_count;
}
/**
* NEW: Delete ALL posts (any author) and all media *except* media authored by admins.
* Returns the total number of items deleted.
* USE WITH EXTREME CAUTION.
*/
private function delete_all_posts_and_non_admin_media() {
// 1. Get admin user IDs (only needed for media exclusion)
$admin_users = get_users( array( 'role__in' => ['administrator'], 'fields' => 'ID' ) );
$admin_ids = !empty($admin_users) ? $admin_users : array(1); // Default exclude user 1 media
// 2. Query ALL posts/pages/custom types (excluding attachments) REGARDLESS OF AUTHOR
$post_types = get_post_types( array('public' => true, 'show_ui' => true), 'names' );
unset($post_types['attachment']); // Handle attachments separately
$deleted_count = 0;
$batch_size = 200; // Process in batches
$log_items = []; // Collect items for logging
foreach ($post_types as $pt) {
while(true) {
$args = array(
'post_type' => $pt,
'post_status' => 'any',
// 'post_author__not_in' => $admin_ids, // NO author exclusion for posts
'posts_per_page' => $batch_size,
'fields' => 'ids',
'orderby' => 'ID',
'order' => 'ASC',
'suppress_filters' => true,
'ignore_sticky_posts' => true,
);
$posts_in_batch = get_posts($args);
if (empty($posts_in_batch)) {
break; // No more posts of this type
}
foreach ($posts_in_batch as $post_id) {
$delete_result = wp_delete_post( $post_id, true ); // Force delete
if ($delete_result) {
$deleted_count++;
$log_items[] = ['type' => $pt, 'id' => $post_id];
}
}
if (count($posts_in_batch) < $batch_size) {
break;
}
// sleep(1); // Optional delay
} // end while loop for batches
} // end foreach post_type
// 3. Query and delete attachments NOT authored by admins
while(true) {
$args = array(
'post_type' => 'attachment',
'post_status' => 'any',
'post_author__not_in' => $admin_ids, // Exclude admin media
'posts_per_page' => $batch_size,
'fields' => 'ids',
'orderby' => 'ID',
'order' => 'ASC',
'suppress_filters' => true,
);
$attachments_in_batch = get_posts($args);
if (empty($attachments_in_batch)) {
break; // No more non-admin attachments
}
foreach ($attachments_in_batch as $att_id) {
$delete_result = wp_delete_attachment( $att_id, true ); // Force delete attachment
if ($delete_result) {
$deleted_count++;
$log_items[] = ['type' => 'attachment', 'id' => $att_id];
}
}
if (count($attachments_in_batch) < $batch_size) {
break;
}
// sleep(1); // Optional delay
}
// 4. Log the bulk deletion action
if (!empty($log_items)) {
$log_entry = sprintf('%s - Deleted %d items (All Posts & Non-Admin Media). Sample IDs: %s...',
current_time('mysql'),
$deleted_count,
implode(', ', array_slice(array_map(function($item){ return $item['type'].':'.$item['id']; }, $log_items), 0, 5)) // Log first 5 deleted items
);
$this->append_log( [$log_entry], $this->all_posts_non_admin_media_log_option, '%s' ); // Use generic log format
}
return $deleted_count;
}
} // End class My_Pro_ID_Image_Cleanup
// Initialize the plugin
My_Pro_ID_Image_Cleanup::init();
?>