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 ''; echo '

' . __('Number of unattached images to check and potentially delete during each scheduled run.', 'my-pro-id') . '

'; } public function field_schedule_cb() { $options = get_option( $this->option_name ); $schedule = ! empty( $options['schedule'] ) ? $options['schedule'] : 'daily'; echo ''; echo '

' . __('How often the automatic image cleanup should run.', 'my-pro-id') . '

'; } public function field_time_cb() { $options = get_option( $this->option_name ); $time = isset( $options['time'] ) ? $options['time'] : 2; echo ''; echo '

' . __('The hour of the day (24-hour format) the cleanup should start.', 'my-pro-id') . '

'; } /** * 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 .= '

' . __('Manual image cleanup executed. Check logs below.', 'my-pro-id') . '

'; } //--- 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 .= '

' . sprintf( __( 'My Pro ID post (empty image) cleanup complete. %d posts were deleted. Check logs below.', 'my-pro-id' ), $deleted_count ) . '

'; } else { $message .= '

' . __( 'No empty (image) My Pro ID posts found to delete.', 'my-pro-id' ) . '

'; } } //--- 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 .= '

' . 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 ) . '

'; } else { $message .= '

' . __( 'No more non-admin normal posts found to delete in this batch.', 'my-pro-id' ) . '

'; } } // --- 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 .= '

' . 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 ) . '

'; } else { $message .= '

' . __( 'No more normal posts found to delete in this batch.', 'my-pro-id' ) . '

'; } } // --- 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 .= '

' . 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 ) . '

'; } else { $message .= '

' . __( 'No My Pro ID posts without a phone number found to delete in this batch.', 'my-pro-id' ) . '

'; } } //--- 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 .= '

' . __('Error:', 'my-pro-id') . ' ' . __('Incorrect password for "Delete All Except Admin". Deletion aborted.', 'my-pro-id') . '

'; } else { // Password matches, proceed with deletion $deleted_all_count = $this->delete_all_posts_and_media_except_admin(); $message .= '

' . sprintf( __('"Delete All Except Admin" complete. %d non-admin posts/media items were removed.', 'my-pro-id'), intval( $deleted_all_count ) ) . '

'; } } // --- 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 .= '

' . __('Error:', 'my-pro-id') . ' ' . __('Incorrect password for "Delete All Posts & Non-Admin Media". Deletion aborted.', 'my-pro-id') . '

'; } else { // Password matches, proceed with deletion $deleted_count = $this->delete_all_posts_and_non_admin_media(); // Call the new function $message .= '

' . 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 ) ) . '

'; } } //--- RENDER THE PAGE CONTENT --- ?>






'; echo '

' . esc_html($title) . '

'; $logs = get_option( $log_option, array() ); if ( ! empty( $logs ) ) { $logs = array_reverse( $logs ); // Newest first $logs = array_slice( $logs, 0, 20 ); echo ''; } else { echo '

' . esc_html( $empty_message ) . '

'; } echo '
'; } // 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. ?>
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(); ?>