# Some Plus Bulk Manager — Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Build a WordPress admin plugin that provides bulk cleanup and management operations via 7 independent modules, each with dry-run preview before execution.

**Architecture:** Module Registry pattern — each feature domain is a class implementing `Interface_Module`. A central `Bulk_Engine` handles dry-run + execute. All AJAX calls go through `Ajax_Handler` with nonce + capability checks.

**Tech Stack:** PHP 7.4+, WordPress 5.0+, vanilla JS (no build step), WP_List_Table for tabular data, CSS custom properties for theming.

**Namespace:** `SomePlusBulkManager`
**Prefix:** `spbm_`
**Constants:** `SPBM_VERSION`, `SPBM_PLUGIN_DIR`, `SPBM_PLUGIN_URL`, `SPBM_PLUGIN_BASENAME`
**Text domain:** `some-plus-bulk-manager`

---

## Task 1: Plugin Entry File & Autoloader

**Files:**
- Create: `some-plus-bulk-manager.php`
- Create: `uninstall.php`
- Create: `includes/index.php` (empty, security)
- Create: `includes/modules/index.php` (empty, security)

**Step 1: Create the main plugin file**

```php
<?php
/**
 * Some Plus Bulk Manager
 *
 * @package           SomePlusBulkManager
 * @author            Some Plus
 * @copyright         2026 Some Plus
 * @license           GPL-2.0-or-later
 *
 * @wordpress-plugin
 * Plugin Name:       Some Plus Bulk Manager
 * Description:       Bulk cleanup and management tools for WordPress admins. Orphaned media, users, comments, revisions, database optimization, and more.
 * Version:           1.0.0
 * Requires at least: 5.0
 * Requires PHP:      7.4
 * Author:            Some Plus
 * Author URI:        https://someplus.work
 * Text Domain:       some-plus-bulk-manager
 * Domain Path:       /languages
 * License:           GPL v2 or later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 */

if ( ! defined( 'WPINC' ) ) {
	die;
}

define( 'SPBM_VERSION', '1.0.0' );
define( 'SPBM_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'SPBM_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'SPBM_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );

spl_autoload_register(
	function ( $class_name ) {
		$prefix = 'SomePlusBulkManager\\';
		if ( strpos( $class_name, $prefix ) !== 0 ) {
			return;
		}
		$relative = substr( $class_name, strlen( $prefix ) );
		$relative = strtolower( str_replace( array( '_', '\\' ), array( '-', '/' ), $relative ) );

		// Check modules subdirectory first.
		$module_file = SPBM_PLUGIN_DIR . 'includes/modules/class-' . $relative . '.php';
		if ( file_exists( $module_file ) ) {
			require_once $module_file;
			return;
		}

		$file = SPBM_PLUGIN_DIR . 'includes/class-' . $relative . '.php';
		if ( file_exists( $file ) ) {
			require_once $file;
		}
	}
);

register_activation_hook( __FILE__, array( 'SomePlusBulkManager\\Activator', 'activate' ) );
register_deactivation_hook( __FILE__, array( 'SomePlusBulkManager\\Deactivator', 'deactivate' ) );

SomePlusBulkManager\Some_Plus_Bulk_Manager::get_instance();
```

**Step 2: Create `uninstall.php`**

```php
<?php
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
	die;
}
delete_option( 'spbm_operation_log' );
delete_option( 'spbm_dashboard_cache' );
```

**Step 3: Create empty index.php files**

Each `index.php` in directories:
```php
<?php // Silence is golden.
```

**Step 4: Verify autoloader path resolution**

Load plugin in WP, check PHP error log for any autoload failures.

**Step 5: Commit**

```bash
git add some-plus-bulk-manager.php uninstall.php includes/index.php includes/modules/index.php
git commit -m "feat: add plugin entry file and autoloader"
```

---

## Task 2: Activator & Deactivator

**Files:**
- Create: `includes/class-activator.php`
- Create: `includes/class-deactivator.php`

**Step 1: Create `class-activator.php`**

```php
<?php
namespace SomePlusBulkManager;

if ( ! defined( 'WPINC' ) ) { die; }

class Activator {
	public static function activate() {
		if ( ! current_user_can( 'activate_plugins' ) ) {
			return;
		}
		update_option( 'spbm_version', SPBM_VERSION );
	}
}
```

**Step 2: Create `class-deactivator.php`**

```php
<?php
namespace SomePlusBulkManager;

if ( ! defined( 'WPINC' ) ) { die; }

class Deactivator {
	public static function deactivate() {
		delete_transient( 'spbm_dashboard_stats' );
	}
}
```

**Step 3: Verify**

Activate/deactivate plugin from WP admin → no errors, `spbm_version` option appears in `wp_options`.

**Step 4: Commit**

```bash
git add includes/class-activator.php includes/class-deactivator.php
git commit -m "feat: add activator and deactivator"
```

---

## Task 3: Main Plugin Class & Module Interface

**Files:**
- Create: `includes/class-some-plus-bulk-manager.php`
- Create: `includes/interface-module.php`

**Step 1: Create `interface-module.php`**

```php
<?php
namespace SomePlusBulkManager;

if ( ! defined( 'WPINC' ) ) { die; }

interface Interface_Module {
	/** Unique slug, e.g. 'media', 'users' */
	public function get_slug(): string;

	/** Admin menu label */
	public function get_label(): string;

	/** Register admin menu page */
	public function register_menu( string $parent_slug ): void;

	/** Enqueue module-specific assets (optional) */
	public function enqueue_assets(): void;
}
```

**Step 2: Create `class-some-plus-bulk-manager.php`**

```php
<?php
namespace SomePlusBulkManager;

if ( ! defined( 'WPINC' ) ) { die; }

class Some_Plus_Bulk_Manager {

	private static $instance = null;
	private $registry        = null;
	private $ajax_handler    = null;

	public static function get_instance(): self {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	private function __construct() {
		add_action( 'init', array( $this, 'init' ) );
	}

	public function init(): void {
		if ( get_option( 'spbm_version' ) !== SPBM_VERSION ) {
			Activator::activate();
		}

		$this->ajax_handler = new Ajax_Handler();

		if ( is_admin() ) {
			$this->registry = new Module_Registry();
			$this->registry->init();
		}
	}

	private function __clone() {}
	public function __wakeup() { throw new \Exception( 'Cannot unserialize singleton' ); }
}
```

**Step 3: Verify**

Plugin loads without fatal errors in WP admin.

**Step 4: Commit**

```bash
git add includes/class-some-plus-bulk-manager.php includes/interface-module.php
git commit -m "feat: add main plugin class and module interface"
```

---

## Task 4: Module Registry

**Files:**
- Create: `includes/class-module-registry.php`

**Step 1: Create `class-module-registry.php`**

```php
<?php
namespace SomePlusBulkManager;

if ( ! defined( 'WPINC' ) ) { die; }

class Module_Registry {

	const PARENT_SLUG = 'some-plus-bulk-manager';
	private array $modules = array();

	public function init(): void {
		$this->register_core_modules();
		$this->maybe_register_woocommerce();
		add_action( 'admin_menu', array( $this, 'register_menus' ) );
		add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_shared_assets' ) );
	}

	private function register_core_modules(): void {
		$classes = array(
			'Module_Media',
			'Module_Users',
			'Module_Comments',
			'Module_Posts',
			'Module_Database',
			'Module_Analytics',
		);
		foreach ( $classes as $class ) {
			$fqn = __NAMESPACE__ . '\\' . $class;
			$this->modules[] = new $fqn();
		}
	}

	private function maybe_register_woocommerce(): void {
		if ( class_exists( 'WooCommerce' ) ) {
			$this->modules[] = new Module_Woocommerce();
		}
	}

	public function register_menus(): void {
		add_menu_page(
			__( 'Bulk Manager', 'some-plus-bulk-manager' ),
			__( 'Bulk Manager', 'some-plus-bulk-manager' ),
			'manage_options',
			self::PARENT_SLUG,
			array( $this, 'render_dashboard' ),
			'dashicons-database-remove',
			80
		);

		// Dashboard submenu (same slug as parent).
		add_submenu_page(
			self::PARENT_SLUG,
			__( 'Dashboard', 'some-plus-bulk-manager' ),
			__( 'Dashboard', 'some-plus-bulk-manager' ),
			'manage_options',
			self::PARENT_SLUG,
			array( $this, 'render_dashboard' )
		);

		foreach ( $this->modules as $module ) {
			$module->register_menu( self::PARENT_SLUG );
		}
	}

	public function render_dashboard(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'Unauthorized.', 'some-plus-bulk-manager' ) );
		}
		require_once SPBM_PLUGIN_DIR . 'templates/admin-dashboard.php';
	}

	public function enqueue_shared_assets( string $hook ): void {
		// Only load on our plugin pages.
		if ( strpos( $hook, self::PARENT_SLUG ) === false &&
		     strpos( $hook, 'spbm-' ) === false ) {
			return;
		}
		wp_enqueue_style(
			'spbm-admin',
			SPBM_PLUGIN_URL . 'assets/css/admin.css',
			array(),
			SPBM_VERSION
		);
		wp_enqueue_script(
			'spbm-admin',
			SPBM_PLUGIN_URL . 'assets/js/admin.js',
			array( 'jquery' ),
			SPBM_VERSION,
			true
		);
		wp_localize_script(
			'spbm-admin',
			'spbm',
			array(
				'ajaxUrl' => admin_url( 'admin-ajax.php' ),
				'nonce'   => wp_create_nonce( 'spbm_nonce' ),
				'i18n'    => array(
					'confirm'    => __( 'Are you sure? This operation cannot be undone.', 'some-plus-bulk-manager' ),
					'processing' => __( 'Processing...', 'some-plus-bulk-manager' ),
					'done'       => __( 'Done!', 'some-plus-bulk-manager' ),
					'error'      => __( 'An error occurred. Please try again.', 'some-plus-bulk-manager' ),
				),
			)
		);
	}

	public function get_modules(): array {
		return $this->modules;
	}
}
```

**Step 2: Verify**

"Bulk Manager" menu appears in WP admin sidebar with a database icon.

**Step 3: Commit**

```bash
git add includes/class-module-registry.php
git commit -m "feat: add module registry with admin menu"
```

---

## Task 5: Bulk Engine

**Files:**
- Create: `includes/class-bulk-engine.php`

**Step 1: Create `class-bulk-engine.php`**

The engine is the heart of all operations. Each module calls it with a query and an operation type.

```php
<?php
namespace SomePlusBulkManager;

if ( ! defined( 'WPINC' ) ) { die; }

class Bulk_Engine {

	const BATCH_SIZE = 500;

	/**
	 * Dry-run: count affected records without making changes.
	 * Returns array with 'count' and optional 'size_bytes'.
	 *
	 * @param callable $count_callback Returns array ['count' => int, 'size_bytes' => int].
	 */
	public static function dry_run( callable $count_callback ): array {
		$result = call_user_func( $count_callback );
		return array(
			'count'      => isset( $result['count'] ) ? (int) $result['count'] : 0,
			'size_bytes' => isset( $result['size_bytes'] ) ? (int) $result['size_bytes'] : 0,
			'preview'    => isset( $result['preview'] ) ? $result['preview'] : array(),
		);
	}

	/**
	 * Execute operation in batches.
	 * $execute_callback receives $batch_size and $offset, returns int (rows affected).
	 *
	 * @param callable $execute_callback ( int $batch_size, int $offset ) : int
	 * @param int      $total_count      Total records to process.
	 * @param string   $module_slug      For logging.
	 * @param string   $operation        For logging.
	 */
	public static function execute(
		callable $execute_callback,
		int $total_count,
		string $module_slug,
		string $operation
	): array {
		$affected = 0;
		$offset   = 0;
		$batches  = 0;

		do {
			$rows    = call_user_func( $execute_callback, self::BATCH_SIZE, $offset );
			$affected += (int) $rows;
			$offset  += self::BATCH_SIZE;
			$batches++;
			// Safety: max 100 batches (50,000 records) per request.
		} while ( $rows >= self::BATCH_SIZE && $batches < 100 );

		self::log( $module_slug, $operation, $affected );

		return array(
			'affected' => $affected,
			'batches'  => $batches,
		);
	}

	/**
	 * Save a short operation log entry to wp_options.
	 */
	private static function log( string $module, string $operation, int $count ): void {
		$log   = get_option( 'spbm_operation_log', array() );
		$log[] = array(
			'time'      => current_time( 'mysql' ),
			'module'    => $module,
			'operation' => $operation,
			'count'     => $count,
		);
		// Keep last 50 entries.
		if ( count( $log ) > 50 ) {
			$log = array_slice( $log, -50 );
		}
		update_option( 'spbm_operation_log', $log, false );
	}

	public static function get_log(): array {
		return get_option( 'spbm_operation_log', array() );
	}

	public static function format_bytes( int $bytes ): string {
		if ( $bytes >= 1048576 ) {
			return round( $bytes / 1048576, 2 ) . ' MB';
		}
		if ( $bytes >= 1024 ) {
			return round( $bytes / 1024, 1 ) . ' KB';
		}
		return $bytes . ' B';
	}
}
```

**Step 2: Commit**

```bash
git add includes/class-bulk-engine.php
git commit -m "feat: add central bulk engine with dry-run and batched execute"
```

---

## Task 6: AJAX Handler

**Files:**
- Create: `includes/class-ajax-handler.php`

**Step 1: Create `class-ajax-handler.php`**

All AJAX actions follow the pattern: verify nonce → check capability → dispatch to module → return JSON.

```php
<?php
namespace SomePlusBulkManager;

if ( ! defined( 'WPINC' ) ) { die; }

class Ajax_Handler {

	public function __construct() {
		$actions = array(
			'spbm_dry_run',
			'spbm_execute',
			'spbm_get_dashboard_stats',
		);
		foreach ( $actions as $action ) {
			add_action( 'wp_ajax_' . $action, array( $this, 'handle_' . $action ) );
		}
	}

	private function verify(): void {
		if ( ! check_ajax_referer( 'spbm_nonce', 'nonce', false ) ) {
			wp_send_json_error( array( 'message' => 'Invalid nonce.' ), 403 );
		}
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( array( 'message' => 'Unauthorized.' ), 403 );
		}
	}

	public function handle_spbm_dry_run(): void {
		$this->verify();
		$module    = sanitize_key( $_POST['module'] ?? '' );
		$operation = sanitize_key( $_POST['operation'] ?? '' );
		$params    = $this->get_params();

		$handler = $this->get_module_handler( $module );
		if ( ! $handler ) {
			wp_send_json_error( array( 'message' => 'Unknown module.' ) );
		}

		$result = $handler->dry_run( $operation, $params );
		wp_send_json_success( $result );
	}

	public function handle_spbm_execute(): void {
		$this->verify();
		$module    = sanitize_key( $_POST['module'] ?? '' );
		$operation = sanitize_key( $_POST['operation'] ?? '' );
		$params    = $this->get_params();

		$handler = $this->get_module_handler( $module );
		if ( ! $handler ) {
			wp_send_json_error( array( 'message' => 'Unknown module.' ) );
		}

		$result = $handler->execute( $operation, $params );
		wp_send_json_success( $result );
	}

	public function handle_spbm_get_dashboard_stats(): void {
		$this->verify();
		$stats = get_transient( 'spbm_dashboard_stats' );
		if ( false === $stats ) {
			$stats = $this->compute_dashboard_stats();
			set_transient( 'spbm_dashboard_stats', $stats, HOUR_IN_SECONDS );
		}
		wp_send_json_success( $stats );
	}

	private function get_params(): array {
		$raw    = $_POST['params'] ?? array();
		$params = array();
		if ( is_array( $raw ) ) {
			foreach ( $raw as $key => $value ) {
				$params[ sanitize_key( $key ) ] = sanitize_text_field( $value );
			}
		}
		return $params;
	}

	private function get_module_handler( string $slug ): ?object {
		$map = array(
			'media'     => 'Module_Media',
			'users'     => 'Module_Users',
			'comments'  => 'Module_Comments',
			'posts'     => 'Module_Posts',
			'database'  => 'Module_Database',
			'analytics' => 'Module_Analytics',
		);
		if ( class_exists( 'WooCommerce' ) ) {
			$map['woocommerce'] = 'Module_Woocommerce';
		}
		if ( ! isset( $map[ $slug ] ) ) {
			return null;
		}
		$class = __NAMESPACE__ . '\\' . $map[ $slug ];
		return class_exists( $class ) ? new $class() : null;
	}

	private function compute_dashboard_stats(): array {
		global $wpdb;
		return array(
			'orphan_media'    => (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'attachment' AND post_parent = 0 AND post_status = 'inherit'" ),
			'revisions'       => (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'revision'" ),
			'auto_drafts'     => (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status = 'auto-draft'" ),
			'spam_comments'   => (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->comments} WHERE comment_approved = 'spam'" ),
			'trashed_posts'   => (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status = 'trash'" ),
			'expired_transients' => (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE %s AND option_name NOT LIKE %s", '_transient_%', '_transient_timeout_%' ) ),
		);
	}
}
```

**Step 2: Commit**

```bash
git add includes/class-ajax-handler.php
git commit -m "feat: add AJAX handler with nonce and capability verification"
```

---

## Task 7: Admin Assets (CSS + JS)

**Files:**
- Create: `assets/css/admin.css`
- Create: `assets/js/admin.js`
- Create: `assets/css/index.php`
- Create: `assets/js/index.php`

**Step 1: Create `assets/css/admin.css`**

```css
/* Some Plus Bulk Manager — Admin Styles */

:root {
    --spbm-primary: #2271b1;
    --spbm-danger: #d63638;
    --spbm-success: #00a32a;
    --spbm-warning: #dba617;
    --spbm-border: #c3c4c7;
    --spbm-bg: #f6f7f7;
}

.spbm-wrap { max-width: 1200px; }

/* Dashboard cards */
.spbm-dashboard-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
    gap: 16px;
    margin: 20px 0;
}
.spbm-card {
    background: #fff;
    border: 1px solid var(--spbm-border);
    border-radius: 4px;
    padding: 16px 20px;
    text-decoration: none;
    color: inherit;
    display: block;
    transition: box-shadow 0.15s;
}
.spbm-card:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.12); color: inherit; }
.spbm-card__count { font-size: 2em; font-weight: 600; color: var(--spbm-primary); line-height: 1; }
.spbm-card__label { font-size: 13px; color: #646970; margin-top: 4px; }
.spbm-card--warning .spbm-card__count { color: var(--spbm-warning); }

/* Module pages */
.spbm-module-header { border-bottom: 1px solid var(--spbm-border); padding-bottom: 12px; margin-bottom: 20px; }
.spbm-filters { background: #fff; border: 1px solid var(--spbm-border); padding: 16px 20px; border-radius: 4px; margin-bottom: 16px; }
.spbm-filters label { display: inline-block; margin-right: 16px; }
.spbm-filters select, .spbm-filters input[type="number"] { margin-left: 6px; }

/* Preview area */
.spbm-preview { background: #fff; border: 1px solid var(--spbm-border); border-radius: 4px; padding: 16px; margin: 16px 0; display: none; }
.spbm-preview.is-visible { display: block; }
.spbm-preview__summary { font-size: 15px; margin-bottom: 12px; }
.spbm-preview__summary strong { color: var(--spbm-primary); }
.spbm-preview__items { max-height: 250px; overflow-y: auto; }

/* Action buttons */
.spbm-actions { margin: 16px 0; display: flex; gap: 10px; align-items: center; }
.spbm-btn { padding: 6px 14px; border-radius: 3px; cursor: pointer; font-size: 13px; border: 1px solid; }
.spbm-btn--preview { background: #f6f7f7; border-color: var(--spbm-border); color: #2c3338; }
.spbm-btn--execute { background: var(--spbm-danger); border-color: #a42123; color: #fff; display: none; }
.spbm-btn--execute.is-visible { display: inline-block; }
.spbm-btn:disabled { opacity: 0.6; cursor: not-allowed; }

/* Progress & status */
.spbm-progress { display: none; align-items: center; gap: 10px; }
.spbm-progress.is-visible { display: flex; }
.spbm-spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid var(--spbm-border); border-top-color: var(--spbm-primary); border-radius: 50%; animation: spbm-spin 0.6s linear infinite; }
@keyframes spbm-spin { to { transform: rotate(360deg); } }

.spbm-notice { padding: 10px 14px; border-radius: 3px; margin: 12px 0; display: none; }
.spbm-notice.is-visible { display: block; }
.spbm-notice--success { background: #edfaef; border-left: 4px solid var(--spbm-success); }
.spbm-notice--error   { background: #fde8e8; border-left: 4px solid var(--spbm-danger); }

/* Operation log */
.spbm-log { margin-top: 30px; }
.spbm-log table { border-collapse: collapse; width: 100%; }
.spbm-log th, .spbm-log td { text-align: left; padding: 8px 12px; border-bottom: 1px solid var(--spbm-border); font-size: 13px; }
.spbm-log th { background: var(--spbm-bg); }
```

**Step 2: Create `assets/js/admin.js`**

```javascript
/* global spbm, jQuery */
( function( $ ) {
    'use strict';

    /**
     * Generic bulk operation form handler.
     * Usage: data-spbm-module="media" data-spbm-operation="orphans" on a <form>.
     */
    $( document ).on( 'submit', '.spbm-form', function( e ) {
        e.preventDefault();
        var $form      = $( this );
        var module     = $form.data( 'spbm-module' );
        var operation  = $form.data( 'spbm-operation' );
        var $preview   = $form.find( '.spbm-preview' );
        var $execBtn   = $form.find( '.spbm-btn--execute' );
        var $previewBtn = $form.find( '.spbm-btn--preview' );
        var $progress  = $form.find( '.spbm-progress' );
        var $notice    = $form.find( '.spbm-notice' );
        var params     = {};

        $form.find( '[name^="spbm_param"]' ).each( function() {
            var key = $( this ).attr( 'name' ).replace( 'spbm_param_', '' );
            params[ key ] = $( this ).val();
        } );

        // Reset state
        $preview.removeClass( 'is-visible' );
        $execBtn.removeClass( 'is-visible' );
        $notice.removeClass( 'is-visible' );
        $previewBtn.prop( 'disabled', true ).text( spbm.i18n.processing );
        $progress.addClass( 'is-visible' );

        $.post( spbm.ajaxUrl, {
            action: 'spbm_dry_run',
            nonce:  spbm.nonce,
            module: module,
            operation: operation,
            params: params
        } )
        .done( function( response ) {
            if ( ! response.success ) {
                showNotice( $notice, 'error', response.data.message || spbm.i18n.error );
                return;
            }
            var data = response.data;
            renderPreview( $preview, data );
            $preview.addClass( 'is-visible' );

            if ( data.count > 0 ) {
                $execBtn.addClass( 'is-visible' )
                    .data( 'count', data.count )
                    .data( 'params', params );
            }
        } )
        .fail( function() {
            showNotice( $notice, 'error', spbm.i18n.error );
        } )
        .always( function() {
            $previewBtn.prop( 'disabled', false ).text( $previewBtn.data( 'original-text' ) || 'Preview' );
            $progress.removeClass( 'is-visible' );
        } );
    } );

    // Store original preview button text on page load
    $( '.spbm-btn--preview' ).each( function() {
        $( this ).data( 'original-text', $( this ).text() );
    } );

    $( document ).on( 'click', '.spbm-btn--execute', function( e ) {
        e.preventDefault();
        var $btn   = $( this );
        var $form  = $btn.closest( '.spbm-form' );
        var count  = $btn.data( 'count' );
        var params = $btn.data( 'params' ) || {};
        var module    = $form.data( 'spbm-module' );
        var operation = $form.data( 'spbm-operation' );
        var $notice   = $form.find( '.spbm-notice' );
        var $progress = $form.find( '.spbm-progress' );

        var confirmMsg = spbm.i18n.confirm + '\n\n' + count + ' record(s) will be affected.';
        if ( ! window.confirm( confirmMsg ) ) {
            return;
        }

        $btn.prop( 'disabled', true );
        $progress.addClass( 'is-visible' );
        $notice.removeClass( 'is-visible' );

        $.post( spbm.ajaxUrl, {
            action: 'spbm_execute',
            nonce:  spbm.nonce,
            module: module,
            operation: operation,
            params: params
        } )
        .done( function( response ) {
            if ( ! response.success ) {
                showNotice( $notice, 'error', response.data.message || spbm.i18n.error );
                return;
            }
            var affected = response.data.affected || 0;
            showNotice( $notice, 'success', spbm.i18n.done + ' ' + affected + ' record(s) processed.' );
            $btn.removeClass( 'is-visible' );
            $form.find( '.spbm-preview' ).removeClass( 'is-visible' );
        } )
        .fail( function() {
            showNotice( $notice, 'error', spbm.i18n.error );
        } )
        .always( function() {
            $btn.prop( 'disabled', false );
            $progress.removeClass( 'is-visible' );
        } );
    } );

    function renderPreview( $container, data ) {
        var html = '<div class="spbm-preview__summary">';
        html += '<strong>' + data.count + '</strong> record(s) will be affected';
        if ( data.size_bytes > 0 ) {
            html += ' &mdash; approx. <strong>' + formatBytes( data.size_bytes ) + '</strong> freed';
        }
        html += '</div>';

        if ( data.preview && data.preview.length ) {
            html += '<ul class="spbm-preview__items">';
            $.each( data.preview.slice( 0, 20 ), function( i, item ) {
                html += '<li>' + escHtml( item ) + '</li>';
            } );
            if ( data.preview.length > 20 ) {
                html += '<li><em>...and ' + ( data.count - 20 ) + ' more</em></li>';
            }
            html += '</ul>';
        }
        $container.html( html );
    }

    function showNotice( $el, type, msg ) {
        $el.removeClass( 'spbm-notice--success spbm-notice--error' )
           .addClass( 'spbm-notice--' + type + ' is-visible' )
           .text( msg );
    }

    function formatBytes( bytes ) {
        if ( bytes >= 1048576 ) { return ( bytes / 1048576 ).toFixed(2) + ' MB'; }
        if ( bytes >= 1024    ) { return ( bytes / 1024    ).toFixed(1) + ' KB'; }
        return bytes + ' B';
    }

    function escHtml( str ) {
        return $( '<div>' ).text( String( str ) ).html();
    }

} )( jQuery );
```

**Step 3: Verify**

Load any plugin page, check browser DevTools Network tab — `admin.css` and `admin.js` load correctly with no console errors.

**Step 4: Commit**

```bash
git add assets/
git commit -m "feat: add admin CSS and JS with dry-run/execute flow"
```

---

## Task 8: Dashboard Template

**Files:**
- Create: `templates/admin-dashboard.php`
- Create: `templates/index.php`

**Step 1: Create `templates/admin-dashboard.php`**

```php
<?php
namespace SomePlusBulkManager;
if ( ! defined( 'WPINC' ) ) { die; }
?>
<div class="wrap spbm-wrap">
    <h1><?php esc_html_e( 'Bulk Manager', 'some-plus-bulk-manager' ); ?></h1>
    <p class="description"><?php esc_html_e( 'Overview of cleanup opportunities. Click a card to go to the module.', 'some-plus-bulk-manager' ); ?></p>

    <div class="spbm-dashboard-grid" id="spbm-dashboard-cards">
        <div class="spbm-card" data-stat="orphan_media">
            <div class="spbm-card__count">—</div>
            <div class="spbm-card__label"><?php esc_html_e( 'Orphaned Media', 'some-plus-bulk-manager' ); ?></div>
        </div>
        <div class="spbm-card" data-stat="revisions">
            <div class="spbm-card__count">—</div>
            <div class="spbm-card__label"><?php esc_html_e( 'Post Revisions', 'some-plus-bulk-manager' ); ?></div>
        </div>
        <div class="spbm-card" data-stat="auto_drafts">
            <div class="spbm-card__count">—</div>
            <div class="spbm-card__label"><?php esc_html_e( 'Auto Drafts', 'some-plus-bulk-manager' ); ?></div>
        </div>
        <div class="spbm-card" data-stat="spam_comments">
            <div class="spbm-card__count">—</div>
            <div class="spbm-card__label"><?php esc_html_e( 'Spam Comments', 'some-plus-bulk-manager' ); ?></div>
        </div>
        <div class="spbm-card" data-stat="trashed_posts">
            <div class="spbm-card__count">—</div>
            <div class="spbm-card__label"><?php esc_html_e( 'Trashed Posts', 'some-plus-bulk-manager' ); ?></div>
        </div>
        <div class="spbm-card" data-stat="expired_transients">
            <div class="spbm-card__count">—</div>
            <div class="spbm-card__label"><?php esc_html_e( 'Expired Transients', 'some-plus-bulk-manager' ); ?></div>
        </div>
    </div>

    <h2><?php esc_html_e( 'Recent Operations', 'some-plus-bulk-manager' ); ?></h2>
    <?php
    $log = Bulk_Engine::get_log();
    if ( empty( $log ) ) :
    ?>
        <p><?php esc_html_e( 'No operations yet.', 'some-plus-bulk-manager' ); ?></p>
    <?php else : ?>
    <div class="spbm-log">
        <table>
            <thead><tr>
                <th><?php esc_html_e( 'Time', 'some-plus-bulk-manager' ); ?></th>
                <th><?php esc_html_e( 'Module', 'some-plus-bulk-manager' ); ?></th>
                <th><?php esc_html_e( 'Operation', 'some-plus-bulk-manager' ); ?></th>
                <th><?php esc_html_e( 'Records', 'some-plus-bulk-manager' ); ?></th>
            </tr></thead>
            <tbody>
            <?php foreach ( array_reverse( $log ) as $entry ) : ?>
                <tr>
                    <td><?php echo esc_html( $entry['time'] ); ?></td>
                    <td><?php echo esc_html( $entry['module'] ); ?></td>
                    <td><?php echo esc_html( $entry['operation'] ); ?></td>
                    <td><?php echo (int) $entry['count']; ?></td>
                </tr>
            <?php endforeach; ?>
            </tbody>
        </table>
    </div>
    <?php endif; ?>
</div>
<script>
jQuery(function($){
    $.post(spbm.ajaxUrl, { action:'spbm_get_dashboard_stats', nonce:spbm.nonce }, function(r){
        if(!r.success) return;
        Object.keys(r.data).forEach(function(key){
            $('[data-stat="'+key+'"] .spbm-card__count').text(r.data[key].toLocaleString());
        });
    });
});
</script>
```

**Step 2: Verify**

Dashboard page loads, stat cards show live numbers after AJAX call.

**Step 3: Commit**

```bash
git add templates/
git commit -m "feat: add dashboard template with live stats cards"
```

---

## Task 9: Module — Media Cleanup

**Files:**
- Create: `includes/modules/class-module-media.php`
- Create: `templates/module-media.php`

**Step 1: Create `class-module-media.php`**

```php
<?php
namespace SomePlusBulkManager;

if ( ! defined( 'WPINC' ) ) { die; }

class Module_Media implements Interface_Module {

	public function get_slug(): string  { return 'media'; }
	public function get_label(): string { return __( 'Media Cleanup', 'some-plus-bulk-manager' ); }

	public function register_menu( string $parent_slug ): void {
		add_submenu_page(
			$parent_slug,
			$this->get_label(),
			$this->get_label(),
			'manage_options',
			'spbm-media',
			array( $this, 'render' )
		);
	}

	public function enqueue_assets(): void {}

	public function render(): void {
		if ( ! current_user_can( 'manage_options' ) ) { wp_die( 'Unauthorized.' ); }
		require SPBM_PLUGIN_DIR . 'templates/module-media.php';
	}

	/** Called by Ajax_Handler */
	public function dry_run( string $operation, array $params ): array {
		switch ( $operation ) {
			case 'orphans':      return $this->dry_run_orphans();
			case 'ghost_files':  return $this->dry_run_ghost_files();
			case 'duplicates':   return $this->dry_run_duplicates();
			default:             return array( 'count' => 0 );
		}
	}

	public function execute( string $operation, array $params ): array {
		switch ( $operation ) {
			case 'orphans':
				return Bulk_Engine::execute(
					array( $this, 'execute_orphans_batch' ),
					$this->dry_run_orphans()['count'],
					$this->get_slug(),
					$operation
				);
			case 'ghost_files':
				return $this->execute_ghost_files();
			default:
				return array( 'affected' => 0 );
		}
	}

	// --- Orphaned attachments ---

	private function dry_run_orphans(): array {
		global $wpdb;
		$rows = $wpdb->get_results(
			"SELECT ID, post_title, post_mime_type
			 FROM {$wpdb->posts}
			 WHERE post_type = 'attachment'
			   AND post_parent = 0
			   AND post_status = 'inherit'
			 LIMIT 500"
		);
		$size = 0;
		$preview = array();
		foreach ( $rows as $row ) {
			$file = get_attached_file( $row->ID );
			if ( $file && file_exists( $file ) ) {
				$size += filesize( $file );
			}
			$preview[] = $row->post_title ?: 'ID:' . $row->ID;
		}
		$total = (int) $wpdb->get_var(
			"SELECT COUNT(*) FROM {$wpdb->posts}
			 WHERE post_type = 'attachment' AND post_parent = 0 AND post_status = 'inherit'"
		);
		return array( 'count' => $total, 'size_bytes' => $size, 'preview' => $preview );
	}

	public function execute_orphans_batch( int $batch_size, int $offset ): int {
		global $wpdb;
		$ids = $wpdb->get_col( $wpdb->prepare(
			"SELECT ID FROM {$wpdb->posts}
			 WHERE post_type = 'attachment' AND post_parent = 0 AND post_status = 'inherit'
			 LIMIT %d",
			$batch_size
		) );
		$deleted = 0;
		foreach ( $ids as $id ) {
			if ( wp_delete_attachment( (int) $id, true ) ) {
				$deleted++;
			}
		}
		return $deleted;
	}

	// --- Ghost files (on disk, not in DB) ---

	private function dry_run_ghost_files(): array {
		$upload_dir = wp_upload_dir();
		$base       = $upload_dir['basedir'];
		$files      = $this->scan_upload_files( $base );
		$count      = 0;
		$size       = 0;
		$preview    = array();
		foreach ( $files as $file ) {
			$url = str_replace( $base, $upload_dir['baseurl'], $file );
			if ( ! $this->file_in_database( $url ) ) {
				$count++;
				$size += filesize( $file );
				$preview[] = basename( $file );
			}
		}
		return array( 'count' => $count, 'size_bytes' => $size, 'preview' => $preview );
	}

	private function execute_ghost_files(): array {
		$upload_dir = wp_upload_dir();
		$base       = $upload_dir['basedir'];
		$files      = $this->scan_upload_files( $base );
		$deleted    = 0;
		foreach ( $files as $file ) {
			$url = str_replace( $base, $upload_dir['baseurl'], $file );
			if ( ! $this->file_in_database( $url ) ) {
				@unlink( $file );
				$deleted++;
			}
		}
		Bulk_Engine::execute( function() { return 0; }, 0, $this->get_slug(), 'ghost_files' );
		return array( 'affected' => $deleted );
	}

	private function scan_upload_files( string $dir ): array {
		$result = array();
		$exts   = array( 'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf', 'mp4', 'mp3' );
		$iter   = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::SKIP_DOTS ) );
		foreach ( $iter as $file ) {
			if ( $file->isFile() && in_array( strtolower( $file->getExtension() ), $exts, true ) ) {
				// Skip thumbnail variants (contain dimensions like -300x200).
				if ( ! preg_match( '/-\d+x\d+\./', $file->getFilename() ) ) {
					$result[] = $file->getPathname();
				}
			}
		}
		return $result;
	}

	private function file_in_database( string $url ): bool {
		global $wpdb;
		return (bool) $wpdb->get_var( $wpdb->prepare(
			"SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type='attachment' AND guid=%s",
			$url
		) );
	}

	// --- Duplicate detection (by md5 hash) ---

	private function dry_run_duplicates(): array {
		global $wpdb;
		$attachments = $wpdb->get_results(
			"SELECT ID FROM {$wpdb->posts} WHERE post_type='attachment' AND post_status='inherit' LIMIT 2000"
		);
		$hashes  = array();
		$dupes   = array();
		$size    = 0;
		$preview = array();
		foreach ( $attachments as $row ) {
			$file = get_attached_file( $row->ID );
			if ( ! $file || ! file_exists( $file ) ) { continue; }
			$hash = md5_file( $file );
			if ( isset( $hashes[ $hash ] ) ) {
				$dupes[]   = $row->ID;
				$size      += filesize( $file );
				$preview[] = basename( $file );
			} else {
				$hashes[ $hash ] = $row->ID;
			}
		}
		return array( 'count' => count( $dupes ), 'size_bytes' => $size, 'preview' => $preview );
	}
}
```

**Step 2: Create `templates/module-media.php`**

```php
<?php
namespace SomePlusBulkManager;
if ( ! defined( 'WPINC' ) ) { die; }
?>
<div class="wrap spbm-wrap">
    <h1 class="spbm-module-header"><?php esc_html_e( 'Media Cleanup', 'some-plus-bulk-manager' ); ?></h1>

    <!-- Orphaned Media -->
    <h2><?php esc_html_e( 'Orphaned Media', 'some-plus-bulk-manager' ); ?></h2>
    <p class="description"><?php esc_html_e( 'Attachments in the media library not linked to any post or page.', 'some-plus-bulk-manager' ); ?></p>
    <form class="spbm-form" data-spbm-module="media" data-spbm-operation="orphans">
        <div class="spbm-actions">
            <button type="submit" class="button spbm-btn spbm-btn--preview"><?php esc_html_e( 'Preview', 'some-plus-bulk-manager' ); ?></button>
            <button type="button" class="button button-primary spbm-btn spbm-btn--execute"><?php esc_html_e( 'Delete Orphans', 'some-plus-bulk-manager' ); ?></button>
            <span class="spbm-progress"><span class="spbm-spinner"></span> <?php esc_html_e( 'Scanning...', 'some-plus-bulk-manager' ); ?></span>
        </div>
        <div class="spbm-preview"></div>
        <div class="spbm-notice"></div>
    </form>

    <hr>

    <!-- Ghost Files -->
    <h2><?php esc_html_e( 'Ghost Files', 'some-plus-bulk-manager' ); ?></h2>
    <p class="description"><?php esc_html_e( 'Physical files in /uploads/ that have no database record. Scan may take a moment on large sites.', 'some-plus-bulk-manager' ); ?></p>
    <form class="spbm-form" data-spbm-module="media" data-spbm-operation="ghost_files">
        <div class="spbm-actions">
            <button type="submit" class="button spbm-btn spbm-btn--preview"><?php esc_html_e( 'Scan', 'some-plus-bulk-manager' ); ?></button>
            <button type="button" class="button button-primary spbm-btn spbm-btn--execute"><?php esc_html_e( 'Delete Ghost Files', 'some-plus-bulk-manager' ); ?></button>
            <span class="spbm-progress"><span class="spbm-spinner"></span></span>
        </div>
        <div class="spbm-preview"></div>
        <div class="spbm-notice"></div>
    </form>

    <hr>

    <!-- Duplicates -->
    <h2><?php esc_html_e( 'Duplicate Images', 'some-plus-bulk-manager' ); ?></h2>
    <p class="description"><?php esc_html_e( 'Detect media files with identical content (MD5 hash). Scans up to 2000 attachments.', 'some-plus-bulk-manager' ); ?></p>
    <form class="spbm-form" data-spbm-module="media" data-spbm-operation="duplicates">
        <div class="spbm-actions">
            <button type="submit" class="button spbm-btn spbm-btn--preview"><?php esc_html_e( 'Detect Duplicates', 'some-plus-bulk-manager' ); ?></button>
            <span class="spbm-progress"><span class="spbm-spinner"></span></span>
        </div>
        <div class="spbm-preview"></div>
        <div class="spbm-notice"></div>
    </form>
</div>
```

**Step 3: Verify**

Navigate to Bulk Manager → Media Cleanup. Click Preview on Orphaned Media → see count. Click Delete → confirm → see success notice.

**Step 4: Commit**

```bash
git add includes/modules/class-module-media.php templates/module-media.php
git commit -m "feat: add media cleanup module (orphans, ghost files, duplicates)"
```

---

## Task 10: Module — Users

**Files:**
- Create: `includes/modules/class-module-users.php`
- Create: `templates/module-users.php`

**Step 1: Create `class-module-users.php`**

```php
<?php
namespace SomePlusBulkManager;

if ( ! defined( 'WPINC' ) ) { die; }

class Module_Users implements Interface_Module {

	public function get_slug(): string  { return 'users'; }
	public function get_label(): string { return __( 'User Management', 'some-plus-bulk-manager' ); }

	public function register_menu( string $parent_slug ): void {
		add_submenu_page( $parent_slug, $this->get_label(), $this->get_label(),
			'manage_options', 'spbm-users', array( $this, 'render' ) );
	}
	public function enqueue_assets(): void {}

	public function render(): void {
		if ( ! current_user_can( 'manage_options' ) ) { wp_die( 'Unauthorized.' ); }
		require SPBM_PLUGIN_DIR . 'templates/module-users.php';
	}

	public function dry_run( string $operation, array $params ): array {
		switch ( $operation ) {
			case 'inactive': return $this->dry_run_inactive( $params );
			case 'orphan_meta': return $this->dry_run_orphan_meta();
			default: return array( 'count' => 0 );
		}
	}

	public function execute( string $operation, array $params ): array {
		switch ( $operation ) {
			case 'inactive':
				return Bulk_Engine::execute(
					function( $batch, $offset ) use ( $params ) {
						return $this->execute_inactive_batch( $params, $batch );
					},
					$this->dry_run_inactive( $params )['count'],
					$this->get_slug(), $operation
				);
			case 'orphan_meta':
				return Bulk_Engine::execute(
					array( $this, 'execute_orphan_meta_batch' ),
					$this->dry_run_orphan_meta()['count'],
					$this->get_slug(), 'orphan_meta'
				);
			default: return array( 'affected' => 0 );
		}
	}

	private function dry_run_inactive( array $params ): array {
		$days = max( 30, (int) ( $params['days'] ?? 180 ) );
		$role = sanitize_key( $params['role'] ?? '' );

		$args = array(
			'number'  => -1,
			'fields'  => array( 'ID', 'user_login', 'user_email' ),
			'meta_query' => array(
				'relation' => 'OR',
				array(
					'key'     => 'last_login',
					'value'   => date( 'Y-m-d H:i:s', strtotime( "-{$days} days" ) ),
					'compare' => '<',
					'type'    => 'DATETIME',
				),
				array(
					'key'     => 'last_login',
					'compare' => 'NOT EXISTS',
				),
			),
			'exclude' => array( get_current_user_id() ),
		);
		if ( $role ) {
			$args['role'] = $role;
		}
		$users   = get_users( $args );
		$preview = array_map( function( $u ) {
			return $u->user_login . ' (' . $u->user_email . ')';
		}, array_slice( $users, 0, 20 ) );

		return array( 'count' => count( $users ), 'preview' => $preview );
	}

	private function execute_inactive_batch( array $params, int $batch ): int {
		$days     = max( 30, (int) ( $params['days'] ?? 180 ) );
		$role     = sanitize_key( $params['role'] ?? '' );
		$reassign = (int) ( $params['reassign_to'] ?? 0 );

		$args = array(
			'number'  => $batch,
			'fields'  => 'ID',
			'meta_query' => array(
				'relation' => 'OR',
				array( 'key' => 'last_login', 'value' => date( 'Y-m-d H:i:s', strtotime( "-{$days} days" ) ), 'compare' => '<', 'type' => 'DATETIME' ),
				array( 'key' => 'last_login', 'compare' => 'NOT EXISTS' ),
			),
			'exclude' => array( get_current_user_id() ),
		);
		if ( $role ) { $args['role'] = $role; }

		$ids     = get_users( $args );
		$deleted = 0;
		foreach ( $ids as $id ) {
			$reassign_id = $reassign ?: null;
			if ( wp_delete_user( (int) $id, $reassign_id ) ) {
				$deleted++;
			}
		}
		return $deleted;
	}

	private function dry_run_orphan_meta(): array {
		global $wpdb;
		$count = (int) $wpdb->get_var(
			"SELECT COUNT(*) FROM {$wpdb->usermeta} um
			 LEFT JOIN {$wpdb->users} u ON um.user_id = u.ID
			 WHERE u.ID IS NULL"
		);
		return array( 'count' => $count );
	}

	public function execute_orphan_meta_batch( int $batch, int $offset ): int {
		global $wpdb;
		return (int) $wpdb->query( $wpdb->prepare(
			"DELETE um FROM {$wpdb->usermeta} um
			 LEFT JOIN {$wpdb->users} u ON um.user_id = u.ID
			 WHERE u.ID IS NULL LIMIT %d",
			$batch
		) );
	}
}
```

**Step 2: Create `templates/module-users.php`**

```php
<?php namespace SomePlusBulkManager; if(!defined('WPINC')){die;} ?>
<div class="wrap spbm-wrap">
    <h1 class="spbm-module-header"><?php esc_html_e('User Management','some-plus-bulk-manager');?></h1>

    <h2><?php esc_html_e('Inactive Users','some-plus-bulk-manager');?></h2>
    <p class="description"><?php esc_html_e('Users who have not logged in for the specified number of days.','some-plus-bulk-manager');?></p>
    <form class="spbm-form" data-spbm-module="users" data-spbm-operation="inactive">
        <div class="spbm-filters">
            <label><?php esc_html_e('Inactive for (days):','some-plus-bulk-manager');?>
                <input type="number" name="spbm_param_days" value="180" min="30" style="width:80px">
            </label>
            <label><?php esc_html_e('Role:','some-plus-bulk-manager');?>
                <select name="spbm_param_role">
                    <option value=""><?php esc_html_e('Any','some-plus-bulk-manager');?></option>
                    <?php wp_dropdown_roles(); ?>
                </select>
            </label>
            <label><?php esc_html_e('Reassign posts to:','some-plus-bulk-manager');?>
                <?php wp_dropdown_users(array('name'=>'spbm_param_reassign_to','show_option_none'=>__('Delete posts','some-plus-bulk-manager'),'option_none_value'=>0,'exclude'=>array(get_current_user_id()))); ?>
            </label>
        </div>
        <div class="spbm-actions">
            <button type="submit" class="button spbm-btn spbm-btn--preview"><?php esc_html_e('Preview','some-plus-bulk-manager');?></button>
            <button type="button" class="button button-primary spbm-btn spbm-btn--execute"><?php esc_html_e('Delete Inactive Users','some-plus-bulk-manager');?></button>
            <span class="spbm-progress"><span class="spbm-spinner"></span></span>
        </div>
        <div class="spbm-preview"></div>
        <div class="spbm-notice"></div>
    </form>
    <hr>
    <h2><?php esc_html_e('Orphaned User Meta','some-plus-bulk-manager');?></h2>
    <p class="description"><?php esc_html_e('usermeta rows belonging to deleted users.','some-plus-bulk-manager');?></p>
    <form class="spbm-form" data-spbm-module="users" data-spbm-operation="orphan_meta">
        <div class="spbm-actions">
            <button type="submit" class="button spbm-btn spbm-btn--preview"><?php esc_html_e('Preview','some-plus-bulk-manager');?></button>
            <button type="button" class="button button-primary spbm-btn spbm-btn--execute"><?php esc_html_e('Clean Up','some-plus-bulk-manager');?></button>
            <span class="spbm-progress"><span class="spbm-spinner"></span></span>
        </div>
        <div class="spbm-preview"></div>
        <div class="spbm-notice"></div>
    </form>
</div>
```

**Step 3: Commit**

```bash
git add includes/modules/class-module-users.php templates/module-users.php
git commit -m "feat: add user management module (inactive users, orphan meta)"
```

---

## Task 11: Module — Comments

**Files:**
- Create: `includes/modules/class-module-comments.php`
- Create: `templates/module-comments.php`

**Step 1: Create `class-module-comments.php`**

```php
<?php
namespace SomePlusBulkManager;
if ( ! defined( 'WPINC' ) ) { die; }

class Module_Comments implements Interface_Module {

	public function get_slug(): string  { return 'comments'; }
	public function get_label(): string { return __( 'Comment Management', 'some-plus-bulk-manager' ); }

	public function register_menu( string $parent_slug ): void {
		add_submenu_page( $parent_slug, $this->get_label(), $this->get_label(),
			'manage_options', 'spbm-comments', array( $this, 'render' ) );
	}
	public function enqueue_assets(): void {}
	public function render(): void {
		if ( ! current_user_can( 'manage_options' ) ) { wp_die( 'Unauthorized.' ); }
		require SPBM_PLUGIN_DIR . 'templates/module-comments.php';
	}

	public function dry_run( string $operation, array $params ): array {
		global $wpdb;
		switch ( $operation ) {
			case 'by_status':
				$status  = in_array( $params['status'] ?? '', array( 'spam', 'trash', '0' ), true ) ? $params['status'] : 'spam';
				$count   = (int) $wpdb->get_var( $wpdb->prepare(
					"SELECT COUNT(*) FROM {$wpdb->comments} WHERE comment_approved = %s", $status
				) );
				return array( 'count' => $count );

			case 'orphaned':
				$count = (int) $wpdb->get_var(
					"SELECT COUNT(*) FROM {$wpdb->comments} c
					 LEFT JOIN {$wpdb->posts} p ON c.comment_post_ID = p.ID
					 WHERE p.ID IS NULL"
				);
				return array( 'count' => $count );

			case 'by_age':
				$days  = max( 1, (int) ( $params['days'] ?? 90 ) );
				$date  = date( 'Y-m-d H:i:s', strtotime( "-{$days} days" ) );
				$count = (int) $wpdb->get_var( $wpdb->prepare(
					"SELECT COUNT(*) FROM {$wpdb->comments} WHERE comment_date < %s", $date
				) );
				return array( 'count' => $count );

			default: return array( 'count' => 0 );
		}
	}

	public function execute( string $operation, array $params ): array {
		$dry  = $this->dry_run( $operation, $params );
		return Bulk_Engine::execute(
			function( $batch, $offset ) use ( $operation, $params ) {
				return $this->execute_batch( $operation, $params, $batch );
			},
			$dry['count'],
			$this->get_slug(),
			$operation
		);
	}

	private function execute_batch( string $operation, array $params, int $batch ): int {
		global $wpdb;
		switch ( $operation ) {
			case 'by_status':
				$status = in_array( $params['status'] ?? '', array( 'spam', 'trash', '0' ), true ) ? $params['status'] : 'spam';
				$ids    = $wpdb->get_col( $wpdb->prepare(
					"SELECT comment_ID FROM {$wpdb->comments} WHERE comment_approved = %s LIMIT %d",
					$status, $batch
				) );
				break;
			case 'orphaned':
				$ids = $wpdb->get_col( $wpdb->prepare(
					"SELECT c.comment_ID FROM {$wpdb->comments} c
					 LEFT JOIN {$wpdb->posts} p ON c.comment_post_ID = p.ID
					 WHERE p.ID IS NULL LIMIT %d",
					$batch
				) );
				break;
			case 'by_age':
				$days = max( 1, (int) ( $params['days'] ?? 90 ) );
				$date = date( 'Y-m-d H:i:s', strtotime( "-{$days} days" ) );
				$ids  = $wpdb->get_col( $wpdb->prepare(
					"SELECT comment_ID FROM {$wpdb->comments} WHERE comment_date < %s LIMIT %d",
					$date, $batch
				) );
				break;
			default: return 0;
		}
		$deleted = 0;
		foreach ( $ids as $id ) {
			if ( wp_delete_comment( (int) $id, true ) ) { $deleted++; }
		}
		return $deleted;
	}
}
```

**Step 2: Create `templates/module-comments.php`** (follow same pattern as media template — 3 forms: by_status, orphaned, by_age)

**Step 3: Commit**

```bash
git add includes/modules/class-module-comments.php templates/module-comments.php
git commit -m "feat: add comment management module"
```

---

## Task 12: Module — Posts & Content

**Files:**
- Create: `includes/modules/class-module-posts.php`
- Create: `templates/module-posts.php`

Operations: `revisions`, `auto_drafts`, `trashed`, `orphan_postmeta`, `bulk_status_change`.

Key queries:
- Revisions: `WHERE post_type='revision'`
- Auto-drafts: `WHERE post_status='auto-draft'`
- Trashed: `WHERE post_status='trash'`
- Orphan postmeta: `LEFT JOIN posts ON post_id = ID WHERE p.ID IS NULL`
- Bulk status change: update `post_status` on filtered posts

Use `wp_delete_post( $id, true )` for permanent deletion of revisions, drafts, trashed posts.

**Step 1:** Follow same class structure as `class-module-comments.php`, implement `dry_run()` and `execute()` per operation.

**Step 2:** Create template with 5 forms.

**Step 3: Commit**

```bash
git add includes/modules/class-module-posts.php templates/module-posts.php
git commit -m "feat: add posts/content management module"
```

---

## Task 13: Module — Database & Taxonomy

**Files:**
- Create: `includes/modules/class-module-database.php`
- Create: `templates/module-database.php`

Operations:
- `transients`: DELETE from `wp_options` WHERE `option_name LIKE '_transient_%'` AND timeout expired
- `optimize`: `OPTIMIZE TABLE wp_posts, wp_postmeta, wp_options, wp_comments, wp_commentmeta, wp_usermeta`
- `orphan_terms`: terms with `term_taxonomy.count = 0`
- `orphan_options`: wp_options rows matching known patterns of removed plugins (user-configurable list)

For `optimize`, the engine pattern doesn't apply — run directly and return table list.

For `orphan_terms`, use `wp_delete_term()` for clean removal.

**Step 1:** Implement class with above operations.
**Step 2:** Create template.
**Step 3: Commit**

```bash
git add includes/modules/class-module-database.php templates/module-database.php
git commit -m "feat: add database and taxonomy cleanup module"
```

---

## Task 14: Module — Analytics & Reporting

**Files:**
- Create: `includes/modules/class-module-analytics.php`
- Create: `templates/module-analytics.php`

This module is **read-only** — no execute operations.

Operations (dry_run only, returns preview data):
- `unviewed`: posts where no pageview meta exists (requires Jetpack or Matomo; gracefully degrade)
- `stale_content`: posts not modified in N days → `WHERE post_modified < date AND post_status='publish'`
- `comment_stats`: GROUP BY post_id ORDER BY comment_count, limit 20
- `broken_links`: extract all `<a href>` from post_content, check HTTP status via `wp_remote_head()`, store results in transient

Broken link check: batch 10 posts per AJAX call, cache results for 24 hours. Return progress status so JS can poll.

**Step 1:** Implement class. Broken link scan should store intermediate results in a transient and accept `offset` param for pagination of the scan.
**Step 2:** Create template.
**Step 3: Commit**

```bash
git add includes/modules/class-module-analytics.php templates/module-analytics.php
git commit -m "feat: add analytics and reporting module"
```

---

## Task 15: Module — WooCommerce (Optional)

**Files:**
- Create: `includes/modules/class-module-woocommerce.php`
- Create: `templates/module-woocommerce.php`

Guard at top of class:
```php
if ( ! class_exists( 'WooCommerce' ) ) { return; }
```

Operations:
- `orphan_product_images`: attachments with `post_parent` pointing to a deleted product
- `old_orders`: orders with `post_status` in `wc-cancelled`, `wc-refunded` older than N days → use `wc_get_orders()` query
- `unused_coupons`: coupons with `usage_count = 0` and `date_expires < now`
- `cart_sessions`: `wp_options` rows WHERE `option_name LIKE '_woocommerce_persistent_cart_%'`

**Step 1:** Implement class with WC-safe queries.
**Step 2:** Create template.
**Step 3: Commit**

```bash
git add includes/modules/class-module-woocommerce.php templates/module-woocommerce.php
git commit -m "feat: add optional WooCommerce cleanup module"
```

---

## Task 16: Languages & Final Polish

**Files:**
- Create: `languages/some-plus-bulk-manager.pot`
- Create: `assets/index.php`, `templates/index.php`

**Step 1:** Generate POT file

```bash
wp i18n make-pot . languages/some-plus-bulk-manager.pot --domain=some-plus-bulk-manager
```

**Step 2:** Add missing index.php security files in all subdirectories.

**Step 3:** Review all templates for missing `esc_html_e()` calls.

**Step 4:** Test full flow end-to-end:
1. Dashboard loads, stats update via AJAX
2. Media → Preview → Execute → log entry appears on dashboard
3. Users → inactive filter → preview shows correct list
4. WooCommerce module hidden on non-WC site

**Step 5: Final commit**

```bash
git add .
git commit -m "feat: complete Some Plus Bulk Manager v1.0.0"
```

---

## Quick Reference

| Module | Slug | Menu Page | Key Operations |
|---|---|---|---|
| Media | `media` | `spbm-media` | orphans, ghost_files, duplicates |
| Users | `users` | `spbm-users` | inactive, orphan_meta |
| Comments | `comments` | `spbm-comments` | by_status, orphaned, by_age |
| Posts | `posts` | `spbm-posts` | revisions, auto_drafts, trashed, orphan_postmeta, bulk_status_change |
| Database | `database` | `spbm-database` | transients, optimize, orphan_terms, orphan_options |
| Analytics | `analytics` | `spbm-analytics` | unviewed, stale_content, comment_stats, broken_links |
| WooCommerce | `woocommerce` | `spbm-woocommerce` | orphan_product_images, old_orders, unused_coupons, cart_sessions |

## Security Checklist (verify before each commit)

- [ ] Every AJAX handler calls `check_ajax_referer()` and `current_user_can('manage_options')`
- [ ] All `$wpdb->prepare()` used for user-supplied values
- [ ] All template output uses `esc_html()`, `esc_attr()`, `esc_url()`
- [ ] No direct `$_POST` access without `sanitize_*` wrapper
- [ ] `if ( ! defined('WPINC') ) { die; }` at top of every PHP file
