Skip to content

Instantly share code, notes, and snippets.

@Jany-M
Last active April 28, 2026 16:26
Show Gist options
  • Select an option

  • Save Jany-M/a57d880c65fe5a87a779bacb6655c39e to your computer and use it in GitHub Desktop.

Select an option

Save Jany-M/a57d880c65fe5a87a779bacb6655c39e to your computer and use it in GitHub Desktop.
[WP] WordPress utility for cleaning up generated image variations (thumbnails) garbage in bulk, and for regenerating thumbnails for specific images. With every deletion or regeneration, it also updates the attachment metadata in the database to remove references to deleted variations.
<?php
/*
WordPress utility for cleaning up generated image variations (thumbnails) in bulk, in a wp-content/uploads/{year} directory (all months), and for regenerating thumbnails for specific images.
With every deletion or regeneration, it also updates the attachment metadata in the database to remove references to deleted variations.
Drop the file in the ROOT of your website (not in plugins) and access it via browser directly after you have logged into your WordPress dashboard first. It only works for administrator users.
ALWAYS backup your database AND files before using it. Delete the file after use.
Made to work on WP Engine, but should work on any host.
Developed by Shambix, a web & mobile development agency with 10+ experience in WordPress.
https://www.shambix.com
info@shambix.com
Last Update: 28 April 2026
Version: 2.0
*/
?>
<?php
define('SHAMBIX_BATCH_SIZE', 100); // Unified batch size for all batch operations
define('SHAMBIX_BATCH_STATE_TTL', 6 * 3600); // Keep batch state for 6 hours (WP-agnostic)
/**
* Build a per-user transient key for a batch run.
*/
function shambix_batch_state_key($run_id, $user_id) {
return 'shambix_batch_' . $user_id . '_' . $run_id;
}
/**
* Format bytes into a human-readable size string.
*/
function shambix_format_bytes($bytes) {
$bytes = max(0, (float) $bytes);
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$power = $bytes > 0 ? (int) floor(log($bytes, 1024)) : 0;
$power = min($power, count($units) - 1);
$value = $bytes / pow(1024, $power);
return number_format($value, $power === 0 ? 0 : 2) . ' ' . $units[$power];
}
/**
* Discover available upload years from wp-content/uploads.
*/
function shambix_get_available_upload_years() {
$uploads = wp_get_upload_dir();
$base_dir = $uploads['basedir'];
$years = [];
if (!is_dir($base_dir)) {
return $years;
}
$entries = @scandir($base_dir);
if (!is_array($entries)) {
return $years;
}
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
if (!preg_match('/^\d{4}$/', $entry)) {
continue;
}
$full = $base_dir . DIRECTORY_SEPARATOR . $entry;
if (is_dir($full)) {
$years[] = $entry;
}
}
rsort($years, SORT_NUMERIC);
return $years;
}
/**
* Resolve an attachment ID from an absolute file path in uploads.
*/
function shambix_get_attachment_id_from_absolute_path($absolute_path) {
global $wpdb;
$absolute_path = wp_normalize_path($absolute_path);
$uploads = wp_get_upload_dir();
$base_dir = wp_normalize_path($uploads['basedir']);
// Only files inside uploads can map to _wp_attached_file.
if (strpos($absolute_path, $base_dir . '/') !== 0) {
return 0;
}
$relative_path = ltrim(substr($absolute_path, strlen($base_dir)), '/');
if ($relative_path === '') {
return 0;
}
$attachment_id = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_wp_attached_file' AND meta_value = %s LIMIT 1",
$relative_path
)
);
return $attachment_id;
}
/**
* Delete a file and clean DB state when the file is the main attachment file.
*/
function shambix_delete_file_with_db_cleanup($path, $dry_run, &$output) {
$attachment_id = shambix_get_attachment_id_from_absolute_path($path);
if ($dry_run) {
return [true, false];
}
// If the file is the original file of an attachment, use core deletion.
if ($attachment_id) {
$deleted_post = wp_delete_attachment($attachment_id, true);
if ($deleted_post) {
$output .= "Deleted attachment + DB rows (ID: {$attachment_id}): " . str_replace(ABSPATH, '', $path) . "\n";
return [true, true];
}
$output .= "Error deleting attachment from DB (ID: {$attachment_id}): " . str_replace(ABSPATH, '', $path) . "\n";
return [false, false];
}
if (@unlink($path)) {
$output .= "Deleted file: " . str_replace(ABSPATH, '', $path) . "\n";
return [true, false];
}
$output .= "Error deleting (permission denied?): " . str_replace(ABSPATH, '', $path) . "\n";
return [false, false];
}
/**
* Returns true when a filename ends with -scaled before extension.
*/
function shambix_is_scaled_filename($filename) {
return (bool) preg_match('/-scaled\.(jpe?g|png|gif|webp)$/i', $filename);
}
/**
* WordPress Generated Image Variation Cleaner for WP Engine
* Place this file in your WordPress *root* directory and access via browser
* Delete the file after you're done for safety reasons
*/
// =====================================================================
// 1. HANDLE BATCH PROCESSING FIRST (BEFORE ANY OUTPUT)
// =====================================================================
if (isset($_POST['action']) && $_POST['action'] === 'process_batch') {
ob_start();
require_once('wp-load.php');
if (!current_user_can('manage_options')) {
wp_die('Access Denied: You must be an administrator to run this script.');
}
set_time_limit(0);
ini_set('memory_limit', '1024M');
ignore_user_abort(true);
try {
$years_csv = isset($_POST['years']) ? sanitize_text_field($_POST['years']) : '';
$selected_years = array_values(array_filter(array_map('trim', explode(',', $years_csv)), function($y) {
return (bool) preg_match('/^\d{4}$/', $y);
}));
$selected_years = array_values(array_unique($selected_years));
$offset = isset($_POST['offset']) ? intval($_POST['offset']) : 0;
$dry_run = !isset($_POST['dry_run']) || (string) $_POST['dry_run'] !== '0';
$run_id = isset($_POST['run_id']) ? sanitize_text_field($_POST['run_id']) : '';
$user_id = get_current_user_id();
$uploads = wp_get_upload_dir();
$UPLOADS_DIR = $uploads['basedir'];
if (!is_dir($UPLOADS_DIR)) {
throw new Exception("Uploads directory does not exist: $UPLOADS_DIR");
}
if (empty($selected_years)) {
throw new Exception('Please select at least one valid year.');
}
$BATCH_SIZE = SHAMBIX_BATCH_SIZE;
$state = null;
$matching_files = null;
$is_new_run = ($offset === 0 && $run_id === '');
// Resume an existing run if run_id was provided.
if (!$is_new_run) {
$state = get_transient(shambix_batch_state_key($run_id, $user_id));
if (is_array($state) && !empty($state['matching_files']) && isset($state['total'])) {
$state_years = isset($state['years']) && is_array($state['years']) ? $state['years'] : [];
sort($state_years, SORT_NUMERIC);
$posted_years = $selected_years;
sort($posted_years, SORT_NUMERIC);
if ($state_years !== $posted_years) {
throw new Exception('Batch year selection mismatch. Start a new run.');
}
if ((bool) ($state['dry_run'] ?? true) !== $dry_run) {
throw new Exception('Batch mode mismatch (dry run/live). Start a new run.');
}
$matching_files = $state['matching_files'];
}
}
// Build the list for a new run or if resume state was unavailable.
if ($matching_files === null) {
if ($run_id === '') {
$run_id = wp_generate_uuid4();
}
$matching_files = [];
foreach ($selected_years as $year) {
$year_dir = $UPLOADS_DIR . DIRECTORY_SEPARATOR . $year;
if (!is_dir($year_dir)) {
continue;
}
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($year_dir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $file) {
if ($file->isDir()) continue;
$filename = $file->getFilename();
if (preg_match('/^(.+)-(\d+)x(\d+)\.(jpe?g|png|gif|webp)$/i', $filename)) {
$matching_files[] = $file->getPathname();
}
}
}
// Ensure stable order across all batches.
sort($matching_files, SORT_NATURAL | SORT_FLAG_CASE);
$state = [
'years' => $selected_years,
'dry_run' => $dry_run,
'matching_files' => $matching_files,
'total' => count($matching_files),
'totals' => [
'deleted' => 0,
'skipped' => 0,
'metadata_updated' => 0,
'attachments_deleted' => 0,
'bytes_saved' => 0,
],
];
set_transient(shambix_batch_state_key($run_id, $user_id), $state, SHAMBIX_BATCH_STATE_TTL);
}
$total_files = (int) ($state['total'] ?? count($matching_files));
if ($offset > $total_files) {
throw new Exception("Invalid offset: {$offset} (total: {$total_files}). Start a new run.");
}
list($deleted, $skipped, $metadata_updated, $attachments_deleted, $bytes_saved, $output, $current) = process_directory_batch(
$UPLOADS_DIR,
$dry_run,
$offset,
$BATCH_SIZE,
$matching_files
);
$finished = ($current >= $total_files);
// Persist cumulative totals for accurate final reporting.
if (!is_array($state)) {
$state = [];
}
if (!isset($state['totals']) || !is_array($state['totals'])) {
$state['totals'] = ['deleted' => 0, 'skipped' => 0, 'metadata_updated' => 0, 'attachments_deleted' => 0, 'bytes_saved' => 0];
}
$state['totals']['deleted'] += $deleted;
$state['totals']['skipped'] += $skipped;
$state['totals']['metadata_updated'] += $metadata_updated;
$state['totals']['attachments_deleted'] += $attachments_deleted;
$state['totals']['bytes_saved'] += $bytes_saved;
$state['total'] = $total_files;
$state['years'] = $selected_years;
$state['dry_run'] = $dry_run;
if ($finished) {
delete_transient(shambix_batch_state_key($run_id, $user_id));
} else {
set_transient(shambix_batch_state_key($run_id, $user_id), $state, SHAMBIX_BATCH_STATE_TTL);
}
ob_end_clean();
header('Content-Type: application/json');
echo json_encode([
'output' => $output,
'run_id' => $run_id,
'years' => $selected_years,
'current' => $current,
'total' => $total_files,
'finished' => $finished,
'deleted' => $deleted,
'skipped' => $skipped,
'metadata_updated' => $metadata_updated,
'attachments_deleted' => $attachments_deleted,
'bytes_saved' => $bytes_saved,
'totals' => $state['totals']
]);
exit;
} catch (Exception $e) {
ob_end_clean();
header('Content-Type: application/json');
echo json_encode([
'error' => 'Error: ' . $e->getMessage(),
'output' => 'Exception occurred during processing'
]);
exit;
}
}
// =====================================================================
// 2. REGULAR PAGE LOAD - REST OF THE SCRIPT
// =====================================================================
require_once('wp-load.php'); // Load WordPress environment
// Only allow administrators to run this script
if (!current_user_can('manage_options')) {
wp_die('Access Denied: You must be an administrator to run this script.');
}
// Only run if we're in a controlled environment
$allowed_ips = ['127.0.0.1', '::1'];
$server_ip = $_SERVER['REMOTE_ADDR'] ?? '';
if (!in_array($server_ip, $allowed_ips) && !current_user_can('manage_options')) {
wp_die('<h1>Access Denied</h1><p>This script can only be run by administrators.</p>');
}
// Disable time limits and memory limits
set_time_limit(0);
ini_set('memory_limit', '1024M');
ignore_user_abort(true);
// Main processing function
function process_directory($dir, $dry_run = true) {
if (!is_dir($dir)) return [0, 0, 0, 0, 0, ''];
$deleted = 0;
$skipped = 0;
$metadata_updated = 0;
$attachments_deleted = 0;
$bytes_saved = 0;
$output = '';
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
$original_files = [];
$attachments_to_update = [];
// First pass: Collect all original files
foreach ($iterator as $file) {
if ($file->isDir()) continue;
$filename = $file->getFilename();
if (preg_match('/^(.+)\.(jpe?g|png|gif|webp)$/i', $filename)) {
$original_files[strtolower($file->getPathname())] = true;
}
}
// Second pass: Process files
foreach ($iterator as $file) {
if ($file->isDir()) continue;
$path = $file->getPathname();
$filename = $file->getFilename();
// Match generated variations
if (preg_match('/^(.+)-(\d+)x(\d+)\.(jpe?g|png|gif|webp)$/i', $filename, $matches)) {
$original_filename = $matches[1] . '.' . $matches[4];
$original_path = $file->getPath() . DIRECTORY_SEPARATOR . $original_filename;
// Keep files tied to "-scaled" originals untouched.
if (shambix_is_scaled_filename($original_filename)) {
$skipped++;
$output .= "Skipped (-scaled protected): " . str_replace(ABSPATH, '', $path) . "\n";
continue;
}
// Check if original exists (case-insensitive)
if (isset($original_files[strtolower($original_path)])) {
$file_size = @filesize($path);
$file_size = $file_size !== false ? (int) $file_size : 0;
if (!$dry_run) {
list($file_deleted, $was_attachment) = shambix_delete_file_with_db_cleanup($path, $dry_run, $output);
if ($file_deleted) {
$deleted++;
$bytes_saved += $file_size;
if ($was_attachment) {
$attachments_deleted++;
}
// Track attachments to update metadata
$attachments_to_update[$original_path] = $original_path;
}
} else {
$deleted++;
$bytes_saved += $file_size;
$output .= "Would delete: " . str_replace(ABSPATH, '', $path) . "\n";
}
} else {
$skipped++;
$output .= "Skipped (no original): " . str_replace(ABSPATH, '', $path) . "\n";
}
}
}
// Update attachment metadata for processed files
if (!$dry_run && !empty($attachments_to_update)) {
foreach ($attachments_to_update as $original_path) {
$attachment_id = shambix_get_attachment_id_from_absolute_path($original_path);
if ($attachment_id) {
$result = clean_attachment_metadata($attachment_id, $dry_run);
if ($result) {
$metadata_updated++;
$output .= "Updated metadata for attachment ID: $attachment_id\n";
}
}
}
}
return [$deleted, $skipped, $metadata_updated, $attachments_deleted, $bytes_saved, $output];
}
// Batch processing function
function process_directory_batch($dir, $dry_run = true, $offset = 0, $batch_size = null, $matching_files = null) {
if ($batch_size === null) $batch_size = SHAMBIX_BATCH_SIZE;
if (!is_dir($dir)) return [0, 0, 0, 0, 0, '', 0];
$deleted = 0;
$skipped = 0;
$metadata_updated = 0;
$attachments_deleted = 0;
$bytes_saved = 0;
$output = '';
$processed_count = 0;
// If matching_files is not provided, build it (for backward compatibility)
if ($matching_files === null) {
$matching_files = [];
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $file) {
if ($file->isDir()) continue;
$filename = $file->getFilename();
if (preg_match('/^(.+)-(\d+)x(\d+)\.(jpe?g|png|gif|webp)$/i', $filename)) {
$matching_files[] = $file->getPathname();
}
}
}
// Only process the current batch slice
$batch_files = array_slice($matching_files, $offset, $batch_size);
$attachments_to_update = [];
foreach ($batch_files as $path) {
$filename = basename($path);
if (preg_match('/^(.+)-(\d+)x(\d+)\.(jpe?g|png|gif|webp)$/i', $filename, $matches)) {
$original_filename = $matches[1] . '.' . $matches[4];
$original_path = dirname($path) . DIRECTORY_SEPARATOR . $original_filename;
// Keep files tied to "-scaled" originals untouched.
if (shambix_is_scaled_filename($original_filename)) {
$skipped++;
$output .= "Skipped (-scaled protected): " . str_replace(ABSPATH, '', $path) . "\n";
$processed_count++;
continue;
}
if (file_exists($original_path)) {
$file_size = @filesize($path);
$file_size = $file_size !== false ? (int) $file_size : 0;
if (!$dry_run) {
list($file_deleted, $was_attachment) = shambix_delete_file_with_db_cleanup($path, $dry_run, $output);
if ($file_deleted) {
$deleted++;
$bytes_saved += $file_size;
if ($was_attachment) {
$attachments_deleted++;
}
$attachments_to_update[$original_path] = $original_path;
}
} else {
$deleted++;
$bytes_saved += $file_size;
$output .= "Would delete: " . str_replace(ABSPATH, '', $path) . "\n";
}
} else {
$output .= "Skipped (original missing): " . str_replace(ABSPATH, '', $path) . "\n";
$skipped++;
}
}
$processed_count++;
}
// Update attachment metadata for processed files
if (!$dry_run && !empty($attachments_to_update)) {
foreach ($attachments_to_update as $original_path) {
$attachment_id = shambix_get_attachment_id_from_absolute_path($original_path);
if ($attachment_id) {
$result = clean_attachment_metadata($attachment_id, $dry_run);
if ($result) {
$metadata_updated++;
$output .= "Updated metadata for attachment ID: $attachment_id\n";
}
}
}
}
// $current is offset + processed_count (number of matching files processed so far)
return [$deleted, $skipped, $metadata_updated, $attachments_deleted, $bytes_saved, $output, $offset + $processed_count];
}
// Clean attachment metadata by removing references to missing files
function clean_attachment_metadata($attachment_id, $dry_run = true) {
$metadata = wp_get_attachment_metadata($attachment_id);
if (empty($metadata['sizes'])) return false;
$original_file = get_attached_file($attachment_id);
if (!$original_file || !file_exists($original_file)) return false;
$base_dir = dirname($original_file);
$updated = false;
$valid_sizes = [];
foreach ($metadata['sizes'] as $size_name => $size_data) {
$file_path = path_join($base_dir, $size_data['file']);
// Check if file exists and has valid dimensions
if (file_exists($file_path)) {
$valid_sizes[$size_name] = $size_data;
} else {
$updated = true;
}
}
if ($updated && !$dry_run) {
$metadata['sizes'] = $valid_sizes;
wp_update_attachment_metadata($attachment_id, $metadata);
}
return $updated;
}
// Regenerate thumbnails for a specific attachment
function regenerate_attachment_thumbs($filename, $dry_run = true) {
$output = '';
$success = false;
// Find attachment by filename
$attachments = get_posts([
'post_type' => 'attachment',
'post_status' => 'inherit',
'posts_per_page' => -1,
'meta_query' => [[
'key' => '_wp_attached_file',
'value' => $filename,
'compare' => 'LIKE'
]]
]);
if (empty($attachments)) {
$output .= "No attachment found for filename: $filename\n";
return [false, $output];
}
// Handle multiple matches
if (count($attachments) > 1) {
$output .= "Found multiple attachments matching filename: $filename\n";
$output .= "Please use the exact relative path (e.g., '2025/05/your-image.jpg')\n";
$output .= "Matching attachments:\n";
foreach ($attachments as $attachment) {
$file = get_post_meta($attachment->ID, '_wp_attached_file', true);
$output .= "- ID {$attachment->ID}: $file\n";
}
return [false, $output];
}
$attachment = reset($attachments);
$attachment_id = $attachment->ID;
$original_file = get_attached_file($attachment_id);
$relative_path = get_post_meta($attachment_id, '_wp_attached_file', true);
if (!$original_file || !file_exists($original_file)) {
$output .= "Original file not found for attachment ID: $attachment_id\n";
$output .= "Path: $original_file\n";
return [false, $output];
}
$output .= "Processing attachment ID: $attachment_id\n";
$output .= "Original file: $relative_path\n";
if (!$dry_run) {
// Regenerate thumbnails
require_once(ABSPATH . 'wp-admin/includes/image.php');
// First clean up existing thumbnails
$metadata = wp_get_attachment_metadata($attachment_id);
$deleted_thumbs = 0;
if (isset($metadata['sizes']) && is_array($metadata['sizes'])) {
$base_dir = dirname($original_file);
foreach ($metadata['sizes'] as $size_name => $size_data) {
$thumb_path = path_join($base_dir, $size_data['file']);
if (file_exists($thumb_path)) {
if (unlink($thumb_path)) {
$output .= "Deleted old thumbnail: " . $size_data['file'] . "\n";
$deleted_thumbs++;
}
}
}
}
if ($deleted_thumbs > 0) {
$output .= "Deleted $deleted_thumbs old thumbnails\n";
} else {
$output .= "No existing thumbnails found to delete\n";
}
// Regenerate metadata
$new_metadata = wp_generate_attachment_metadata($attachment_id, $original_file);
if (is_wp_error($new_metadata)) {
$output .= "Error regenerating metadata: " . $new_metadata->get_error_message() . "\n";
} else {
wp_update_attachment_metadata($attachment_id, $new_metadata);
$output .= "Successfully regenerated thumbnails!\n";
$success = true;
// List new thumbnails
if (!empty($new_metadata['sizes'])) {
$output .= "Generated thumbnails:\n";
foreach ($new_metadata['sizes'] as $size => $size_data) {
$output .= "- {$size}: {$size_data['width']}x{$size_data['height']} ({$size_data['file']})\n";
}
} else {
$output .= "No thumbnails generated (check registered image sizes)\n";
}
}
} else {
$output .= "Would regenerate thumbnails for attachment ID: $attachment_id\n";
$output .= "Relative path: $relative_path\n";
$success = true; // For dry run reporting
}
return [$success, $output];
}
// Start HTML output
?>
<!DOCTYPE html>
<html>
<head>
<title>Image Variation Manager</title>
<style>
:root {
--bg: #f3f6fb;
--surface: #ffffff;
--surface-muted: #f8faff;
--border: #d8e1f0;
--text: #1f2a44;
--text-soft: #5f6f8d;
--primary: #2f6df6;
--primary-dark: #2458c8;
--success: #1f8f49;
--danger: #c62828;
--warning: #9a6700;
--shadow: 0 10px 35px rgba(23, 40, 77, 0.08);
--radius: 14px;
}
* { box-sizing: border-box; }
body {
font-family: "Inter", "Segoe UI", "Helvetica Neue", Arial, sans-serif;
line-height: 1.6;
margin: 0;
background: radial-gradient(circle at 20% 0%, #ffffff 0%, var(--bg) 48%);
color: var(--text);
}
.app-shell { max-width: 1080px; margin: 38px auto; padding: 0 18px 28px; }
.app-header {
background: linear-gradient(135deg, #2f6df6 0%, #5b8cff 100%);
color: #fff;
border-radius: var(--radius);
padding: 22px 24px;
box-shadow: var(--shadow);
margin-bottom: 18px;
}
.app-header h2 { margin: 0 0 6px; font-size: 24px; }
.app-header p { margin: 0; color: #e8eeff; }
.warning-banner {
margin: 0 0 18px;
background: #fff8e8;
border: 1px solid #f5deb3;
color: #7a5300;
border-radius: 12px;
padding: 12px 14px;
}
.tabs { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 14px; }
.tab {
padding: 10px 16px;
cursor: pointer;
border: 1px solid var(--border);
background: var(--surface);
border-radius: 10px;
color: var(--text-soft);
font-weight: 600;
transition: all .2s ease;
}
.tab.active {
background: #e9f0ff;
border-color: #9ab8f8;
color: #113477;
box-shadow: 0 6px 18px rgba(47, 109, 246, 0.15);
}
.tab-content {
display: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 22px;
box-shadow: var(--shadow);
}
.tab-content.active { display: block; }
pre {
background: #f5f8ff;
border: 1px solid #d7e2f8;
border-radius: 10px;
padding: 12px;
overflow: auto;
max-height: 400px;
color: #1b2a4b;
}
.success { color: var(--success); }
.error { color: var(--danger); }
.warning { color: var(--warning); }
.warning-box {
margin-top: 16px;
border-radius: 10px;
border: 1px solid #ffe0b2;
background: #fffaf0;
padding: 12px 14px;
}
input[type="text"], select {
width: 100%;
max-width: 420px;
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 12px;
font-size: 14px;
color: var(--text);
background: var(--surface-muted);
}
.progress-container {
width: 100%;
background: #e8efff;
margin: 16px 0 10px;
border-radius: 999px;
overflow: hidden;
border: 1px solid #c8d8fb;
}
.progress-bar {
width: 0%;
height: 32px;
background: linear-gradient(90deg, #2f6df6, #5d8bff);
text-align: center;
line-height: 32px;
color: white;
font-weight: 600;
}
.btn {
padding: 10px 16px;
background: var(--primary);
color: white;
border: none;
border-radius: 10px;
cursor: pointer;
font-weight: 600;
margin-right: 8px;
}
.btn:disabled { background: #9fb3db; cursor: not-allowed; }
.btn-secondary { background: #5f6f8d; }
.batch-section { margin-top: 18px; padding-top: 12px; }
.year-grid { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 6px; }
.year-pill {
display: inline-flex;
align-items: center;
gap: 7px;
border: 1px solid var(--border);
background: var(--surface-muted);
border-radius: 999px;
padding: 7px 12px;
}
.log-panel {
height: 300px;
overflow: auto;
border: 1px solid var(--border);
border-radius: 10px;
background: #f7f9fe;
padding: 12px;
margin: 14px 0 16px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
margin: 8px 0 10px;
}
.stat-card {
border: 1px solid var(--border);
border-radius: 10px;
background: #f9fbff;
padding: 10px 12px;
}
.stat-label {
display: block;
color: var(--text-soft);
font-size: 12px;
margin-bottom: 4px;
}
.stat-value {
display: block;
color: var(--text);
font-size: 20px;
font-weight: 700;
line-height: 1.1;
}
.footer-note { margin-top: 18px; color: #6b7c9c; font-size: 13px; text-align: center; }
hr { border: 0; border-top: 1px solid #dde6f7; margin: 18px 0; }
</style>
<script>
// Initialize variables for batch processing
let paused = false;
let currentOffset = 0;
let totalFiles = 0;
let dryRun = true;
let selectedYears = [];
let runId = null;
function setStatValue(id, value) {
const el = document.getElementById(id);
if (el) {
el.textContent = value;
}
}
function formatBytes(bytes) {
const value = Number(bytes) || 0;
if (value <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const idx = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
const scaled = value / Math.pow(1024, idx);
return `${scaled.toFixed(idx === 0 ? 0 : 2)} ${units[idx]}`;
}
function resetStats() {
setStatValue('stat-deleted', '0');
setStatValue('stat-skipped', '0');
setStatValue('stat-metadata', '0');
setStatValue('stat-attachments', '0');
setStatValue('stat-space', '0 B');
setStatValue('stat-batch-deleted', '0');
setStatValue('stat-batch-skipped', '0');
setStatValue('stat-batch-metadata', '0');
setStatValue('stat-batch-attachments', '0');
setStatValue('stat-batch-space', '0 B');
}
function openTab(evt, tabName) {
var i, tabcontent, tablinks;
tabcontent = document.getElementsByClassName("tab-content");
for (i = 0; i < tabcontent.length; i++) {
tabcontent[i].classList.remove("active");
}
tablinks = document.getElementsByClassName("tab");
for (i = 0; i < tablinks.length; i++) {
tablinks[i].classList.remove("active");
}
document.getElementById(tabName).classList.add("active");
evt.currentTarget.classList.add("active");
// Reset batch UI when switching to batch tab
if (tabName === 'cleaner-batch') {
document.getElementById('batch-output').innerHTML = '';
document.getElementById('progress-bar').style.width = '0%';
document.getElementById('progress-bar').innerHTML = '0%';
document.getElementById('progress-bar').style.backgroundColor = '#4CAF50';
document.getElementById('progress-text').innerHTML = 'Processed: 0/0 files';
currentOffset = 0;
resetStats();
}
}
function startBatchProcessing() {
const selected = document.querySelectorAll('input[name="batch_years[]"]:checked');
selectedYears = Array.from(selected).map((el) => el.value);
if (selectedYears.length === 0) {
alert('Please select at least one year.');
return;
}
dryRun = !document.getElementById('batch_confirm').checked;
paused = false;
document.getElementById('batch-output').innerHTML = '';
document.getElementById('progress-bar').style.width = '0%';
document.getElementById('progress-bar').innerHTML = '0%';
document.getElementById('progress-bar').style.backgroundColor = '#4CAF50';
document.getElementById('progress-text').innerHTML = 'Processed: 0/0 files';
currentOffset = 0;
totalFiles = 0;
runId = null;
resetStats();
document.getElementById('start-btn').disabled = true;
document.getElementById('pause-btn').disabled = false;
document.getElementById('resume-btn').disabled = true;
processBatch();
}
function pauseProcessing() {
paused = true;
document.getElementById('pause-btn').disabled = true;
document.getElementById('resume-btn').disabled = false;
}
function resumeProcessing() {
paused = false;
document.getElementById('pause-btn').disabled = false;
document.getElementById('resume-btn').disabled = true;
processBatch();
}
function processBatch() {
if (paused) return;
const outputElement = document.getElementById('batch-output');
const progressBar = document.getElementById('progress-bar');
const progressText = document.getElementById('progress-text');
const xhr = new XMLHttpRequest();
xhr.open('POST', '<?php echo $_SERVER['PHP_SELF']; ?>', true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
try {
const response = JSON.parse(xhr.responseText);
if (response.error) {
outputElement.innerHTML += '<br><span class="error">' + response.error + '</span>';
document.getElementById('start-btn').disabled = false;
document.getElementById('pause-btn').disabled = true;
return;
}
// Update output
if (response.output) {
outputElement.innerHTML += response.output.replace(/\n/g, '<br>');
outputElement.scrollTop = outputElement.scrollHeight;
}
if (response.run_id) {
runId = response.run_id;
}
if (typeof response.total === 'number') {
totalFiles = response.total;
}
setStatValue('stat-batch-deleted', response.deleted ?? 0);
setStatValue('stat-batch-skipped', response.skipped ?? 0);
setStatValue('stat-batch-metadata', response.metadata_updated ?? 0);
setStatValue('stat-batch-attachments', response.attachments_deleted ?? 0);
setStatValue('stat-batch-space', formatBytes(response.bytes_saved ?? 0));
const totals = response.totals || {};
setStatValue('stat-deleted', totals.deleted ?? 0);
setStatValue('stat-skipped', totals.skipped ?? 0);
setStatValue('stat-metadata', totals.metadata_updated ?? 0);
setStatValue('stat-attachments', totals.attachments_deleted ?? 0);
setStatValue('stat-space', formatBytes(totals.bytes_saved ?? 0));
const progress = totalFiles > 0 ? Math.round((response.current / totalFiles) * 100) : 0;
progressBar.style.width = progress + '%';
progressBar.innerHTML = progress + '%';
progressText.innerHTML = `Processed: ${response.current}/${totalFiles} files`;
currentOffset = response.current;
// Continue processing if not finished
if (response.finished) {
progressBar.style.backgroundColor = '#4CAF50';
progressBar.innerHTML = 'Completed!';
progressText.innerHTML = `Finished! Processed ${totalFiles} files`;
document.getElementById('start-btn').disabled = false;
document.getElementById('pause-btn').disabled = true;
runId = null;
outputElement.innerHTML += `<br><br><b>Summary:</b><br>Files deleted: ${totals.deleted ?? 0}<br>Files skipped: ${totals.skipped ?? 0}<br>Metadata updated: ${totals.metadata_updated ?? 0}<br>Attachment records deleted: ${totals.attachments_deleted ?? 0}<br>Space reclaimed: ${formatBytes(totals.bytes_saved ?? 0)}`;
} else if (!paused) {
setTimeout(processBatch, 500);
}
} catch (e) {
outputElement.innerHTML += '<br><span class="error">Error parsing response: ' + e.message + '</span>';
document.getElementById('start-btn').disabled = false;
document.getElementById('pause-btn').disabled = true;
}
} else {
outputElement.innerHTML += '<br><span class="error">Server Error: ' + xhr.statusText + '</span>';
document.getElementById('start-btn').disabled = false;
document.getElementById('pause-btn').disabled = true;
}
}
};
xhr.onerror = function() {
outputElement.innerHTML += '<br><span class="error">Request failed. Check console for details.</span>';
document.getElementById('start-btn').disabled = false;
document.getElementById('pause-btn').disabled = true;
};
// Send request, always include matchingFiles if available
let postData = `action=process_batch&years=${encodeURIComponent(selectedYears.join(','))}&offset=${currentOffset}&dry_run=${dryRun ? 1 : 0}`;
if (runId) {
postData += `&run_id=${encodeURIComponent(runId)}`;
}
xhr.send(postData);
}
</script>
</head>
<body>
<div class="app-shell">
<div class="app-header">
<h2>WordPress Thumbs Cleaner - by <a href="https://www.shambix.com" target="_blank">Shambix</a></h2>
<p>Clean generated image variations and regenerate thumbnails with safer batch processing.</p>
</div>
<p class="warning-banner"><strong>Please ensure you have a backup of your site before proceeding, deletions are irreversible. Use this script at your own risk and delete it after use.</strong></p>
<?php $available_years = shambix_get_available_upload_years(); ?>
<div class="tabs">
<div class="tab active" onclick="openTab(event, 'cleaner-batch')">Clean Variations (Batch)</div>
<div class="tab" onclick="openTab(event, 'cleaner-standard')">Clean Variations (Standard)</div>
<div class="tab" onclick="openTab(event, 'regenerate')">Regenerate Thumbnails</div>
</div>
<?php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (isset($_POST['action']) && $_POST['action'] === 'regenerate') {
// Handle thumbnail regeneration
$dry_run = empty($_POST['confirm']);
$filename = isset($_POST['filename']) ? sanitize_text_field($_POST['filename']) : '';
echo "<div id='regenerate' class='tab-content active'>";
echo "<h2>Regenerate Thumbnails</h2>";
if (empty($filename)) {
echo "<p class='error'>Please enter a filename</p>";
} else {
list($success, $output) = regenerate_attachment_thumbs($filename, $dry_run);
echo "<p>Target file: $filename</p>";
echo "<p>Mode: " . ($dry_run ? "DRY RUN (no changes)" : "LIVE REGENERATION") . "</p>";
echo "<hr>";
echo "<pre>$output</pre>";
echo "<hr>";
if ($success) {
echo "<p class='success'>Operation completed successfully!</p>";
} else {
echo "<p class='error'>There were errors during processing</p>";
}
}
echo '<p><a href="'.esc_url($_SERVER['REQUEST_URI']).'">Run again</a></p>';
echo "</div>";
} else if (isset($_POST['action']) && $_POST['action'] === 'clean_standard') {
// Handle standard image cleanup
$dry_run = empty($_POST['confirm']);
$year = isset($_POST['year']) ? sanitize_text_field($_POST['year']) : (isset($available_years[0]) ? $available_years[0] : date('Y'));
$YEAR_DIR = ABSPATH . 'wp-content/uploads/' . $year;
echo "<div id='cleaner-standard' class='tab-content active'>";
echo "<h2>Processing: $year</h2>";
echo "<p>Target directory: " . str_replace(ABSPATH, '', $YEAR_DIR) . "</p>";
echo "<p>Mode: " . ($dry_run ? "DRY RUN (no changes)" : "LIVE DELETION") . "</p>";
echo "<hr>";
list($total_deleted, $total_skipped, $metadata_updated, $attachments_deleted, $bytes_saved, $output) = process_directory($YEAR_DIR, $dry_run);
echo "<pre>$output</pre>";
echo "<hr>";
echo "<h3>Summary:</h3>";
echo "<p>Total variations deleted: $total_deleted</p>";
echo "<p>Total variations skipped: $total_skipped</p>";
echo "<p>Metadata updated (unique attachments ID): $metadata_updated</p>";
echo "<p>Attachment records deleted: $attachments_deleted</p>";
echo "<p>Space reclaimed: " . shambix_format_bytes($bytes_saved) . "</p>";
echo "<p>Original files preserved</p>";
if ($dry_run) {
echo '<p class="warning">NOTE: Run in LIVE mode to perform actual deletion and metadata updates</p>';
}
echo '<p><a href="'.esc_url($_SERVER['REQUEST_URI']).'">Run again</a></p>';
echo "</div>";
}
} else {
// Show forms
?>
<!-- Standard Cleaner Tab -->
<div id="cleaner-standard" class="tab-content">
<h2>Clean Image Variations (Standard)</h2>
<form method="post">
<input type="hidden" name="action" value="clean_standard">
<p>
<label for="standard_year">Year to process:</label>
<select id="standard_year" name="year" required>
<?php if (empty($available_years)): ?>
<option value="<?php echo esc_attr(date('Y')); ?>"><?php echo esc_html(date('Y')); ?></option>
<?php else: ?>
<?php foreach ($available_years as $year_option): ?>
<option value="<?php echo esc_attr($year_option); ?>"><?php echo esc_html($year_option); ?></option>
<?php endforeach; ?>
<?php endif; ?>
</select>
</p>
<p>
<label>
<input type="checkbox" name="confirm" value="1">
Check to confirm actual deletion (otherwise dry run)
</label>
</p>
<p>
<input type="submit" value="Process Images" class="btn">
</p>
</form>
<div class="warning-box">
<h3>Important Notes:</h3>
<ul>
<li>Best for small to medium directories</li>
<li>Backup your site before running this script</li>
<li>Dry run mode is enabled by default</li>
<li>Only checks image variations in the specified year directory</li>
<li>Will not delete original images</li>
<li>Will automatically update attachment metadata</li>
<li>Process might time out for large directories</li>
</ul>
</div>
</div>
<!-- Batch Cleaner Tab -->
<div id="cleaner-batch" class="tab-content active">
<h2>Clean Image Variations (Batch)</h2>
<p>
<strong>Years to process:</strong>
</p>
<?php if (empty($available_years)): ?>
<p class="error">No year folders were found in uploads.</p>
<?php else: ?>
<div class="year-grid">
<?php foreach ($available_years as $index => $year_option): ?>
<label class="year-pill">
<input type="checkbox" name="batch_years[]" value="<?php echo esc_attr($year_option); ?>" <?php echo $index === 0 ? 'checked' : ''; ?>>
<?php echo esc_html($year_option); ?>
</label>
<?php endforeach; ?>
</div>
<p><small>Select one or multiple years to run in one batch job.</small></p>
<?php endif; ?>
<p>
<label>
<input type="checkbox" id="batch_confirm" name="confirm" value="1">
Check to confirm actual deletion (otherwise dry run)
</label>
</p>
<div class="batch-section">
<div class="stats-grid">
<div class="stat-card">
<span class="stat-label">Total Deleted</span>
<span class="stat-value" id="stat-deleted">0</span>
</div>
<div class="stat-card">
<span class="stat-label">Total Skipped</span>
<span class="stat-value" id="stat-skipped">0</span>
</div>
<div class="stat-card">
<span class="stat-label">Metadata Updated</span>
<span class="stat-value" id="stat-metadata">0</span>
</div>
<div class="stat-card">
<span class="stat-label">Attachments Deleted</span>
<span class="stat-value" id="stat-attachments">0</span>
</div>
<div class="stat-card">
<span class="stat-label">Total Space Reclaimed</span>
<span class="stat-value" id="stat-space">0 B</span>
</div>
<div class="stat-card">
<span class="stat-label">Current Batch Deleted</span>
<span class="stat-value" id="stat-batch-deleted">0</span>
</div>
<div class="stat-card">
<span class="stat-label">Current Batch Skipped</span>
<span class="stat-value" id="stat-batch-skipped">0</span>
</div>
<div class="stat-card">
<span class="stat-label">Current Batch Metadata</span>
<span class="stat-value" id="stat-batch-metadata">0</span>
</div>
<div class="stat-card">
<span class="stat-label">Current Batch Attachments</span>
<span class="stat-value" id="stat-batch-attachments">0</span>
</div>
<div class="stat-card">
<span class="stat-label">Batch Space Reclaimed</span>
<span class="stat-value" id="stat-batch-space">0 B</span>
</div>
</div>
<div class="progress-container">
<div id="progress-bar" class="progress-bar">0%</div>
</div>
<p id="progress-text">Processed: 0/0 files</p>
<div id="batch-output" class="log-panel"></div>
<button id="start-btn" class="btn" onclick="startBatchProcessing()" <?php echo empty($available_years) ? 'disabled' : ''; ?>>Start Batch Processing</button>
<button id="pause-btn" class="btn btn-secondary" onclick="pauseProcessing()" disabled>Pause</button>
<button id="resume-btn" class="btn btn-secondary" onclick="resumeProcessing()" disabled>Resume</button>
</div>
<div class="warning-box">
<h3>Important Notes:</h3>
<ul>
<li>For large directories (1000+ files)</li>
<li>Processes in batches to prevent timeouts</li>
<li>Backup your site before running this script</li>
<li>Dry run mode is enabled by default</li>
<li>Only checks image variations in the specified year directory</li>
<li>Will not delete original images</li>
<li>Will automatically update attachment metadata</li>
</ul>
</div>
</div>
<!-- Regenerate Thumbnails Tab -->
<div id="regenerate" class="tab-content">
<h2>Regenerate Thumbnails</h2>
<form method="post">
<input type="hidden" name="action" value="regenerate">
<p>
<label for="filename">File path:</label>
<input type="text" id="filename" name="filename" required>
<span>(e.g., 2025/05/ABB25_2500x650.jpg)</span>
</p>
<p class="warning">
<strong>Important:</strong> Use the relative path as shown in Media Library<br>
(e.g., "2025/05/your-image.jpg" or just "your-image.jpg" if unique)
</p>
<p>
<label>
<input type="checkbox" name="confirm" value="1">
Check to confirm actual regeneration (otherwise dry run)
</label>
</p>
<p>
<input type="submit" value="Regenerate Thumbnails" class="btn">
</p>
</form>
<div class="warning-box">
<h3>Important Notes:</h3>
<ul>
<li>Enter the exact filename as it appears in the Media Library</li>
<li>For best results, use the full relative path (e.g., 2025/05/filename.jpg)</li>
<li>If multiple matches found, the script will show options</li>
<li>This will delete existing thumbnails and generate new ones</li>
<li>Dry run mode will show what would be generated</li>
<li>Thumbnails will be created for all registered image sizes</li>
</ul>
</div>
</div>
<?php
}
?>
<hr/>
<p class="footer-note">This <small><a href="https://gist.github.com/Jany-M/a57d880c65fe5a87a779bacb6655c39e" target="_blank">Gist</a> is on Github. <a href="https://www.shambix.com" target="_blank">Contact the developers</a> if you need help with WordPress maintenance, custom plugins/themes, web/mobile apps, or cloud setup services.</small></p>
</div>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment