diff --git a/includes/admin/class-media-library.php b/includes/admin/class-media-library.php
index 901c67f..f10b27d 100644
--- a/includes/admin/class-media-library.php
+++ b/includes/admin/class-media-library.php
@@ -9,6 +9,7 @@
use WebberZone\Image_Optimizer\Attachment_Meta;
use WebberZone\Image_Optimizer\Converter;
+use WebberZone\Image_Optimizer\Database;
use WebberZone\Image_Optimizer\Processor;
use WebberZone\Image_Optimizer\Queue;
use WebberZone\Image_Optimizer\Util\Helpers;
@@ -25,6 +26,21 @@
*/
class Media_Library {
+ /**
+ * Query-string key for the optimization status filter.
+ *
+ * @since 1.1.0
+ * @var string
+ */
+ private const STATUS_FILTER = 'wzio_optimization_status';
+
+ /**
+ * Internal query variable for the requested optimization status.
+ *
+ * @since 1.1.0
+ * @var string
+ */
+ private const STATUS_QUERY_VAR = 'wzio_filter_status';
/**
* Constructor.
@@ -37,6 +53,9 @@ public function __construct() {
Hook_Registry::add_filter( 'media_row_actions', array( $this, 'add_row_actions' ), 10, 2 );
Hook_Registry::add_filter( 'bulk_actions-upload', array( $this, 'add_bulk_actions' ) );
Hook_Registry::add_filter( 'handle_bulk_actions-upload', array( $this, 'handle_bulk_restore' ), 10, 3 );
+ Hook_Registry::add_action( 'restrict_manage_posts', array( $this, 'render_status_filter' ), 10, 2 );
+ Hook_Registry::add_action( 'pre_get_posts', array( $this, 'filter_by_status' ) );
+ Hook_Registry::add_filter( 'posts_where', array( $this, 'filter_attachments_by_status' ), 10, 2 );
Hook_Registry::add_action( 'admin_post_wzio_optimize_attachment', array( $this, 'handle_optimize' ) );
Hook_Registry::add_action( 'admin_post_wzio_restore_attachment', array( $this, 'handle_restore' ) );
Hook_Registry::add_action( 'wp_ajax_wzio_optimize_attachment', array( $this, 'ajax_optimize' ) );
@@ -152,6 +171,153 @@ public function add_column( $columns ) {
return $columns;
}
+ /**
+ * Add the optimization status filter to the media list table.
+ *
+ * @since 1.1.0
+ *
+ * @param string $post_type Current post type.
+ * @param string $which Location of the filter controls.
+ * @return void
+ */
+ public function render_status_filter( $post_type, $which ): void {
+ if ( 'attachment' !== $post_type || 'bar' !== $which || ! self::can_view_status_filter() ) {
+ return;
+ }
+
+ $selected = self::get_requested_status();
+ $options = array(
+ '' => __( 'All optimization statuses', 'webberzone-image-optimizer' ),
+ 'optimized' => __( 'Optimized', 'webberzone-image-optimizer' ),
+ 'unoptimized' => __( 'Not yet optimized', 'webberzone-image-optimizer' ),
+ 'skipped' => __( 'Skipped', 'webberzone-image-optimizer' ),
+ 'failed' => __( 'Failed', 'webberzone-image-optimizer' ),
+ );
+ ?>
+
+
+ is_main_query()
+ || 'attachment' !== $query->get( 'post_type' )
+ || ! current_user_can( 'upload_files' )
+ ) {
+ return;
+ }
+
+ $status = self::get_requested_status();
+
+ if ( '' !== $status ) {
+ $query->set( self::STATUS_QUERY_VAR, $status );
+ }
+ }
+
+ /**
+ * Limit a flagged media query to eligible attachments in the requested queue state.
+ *
+ * @since 1.1.0
+ *
+ * @param string $where Current WHERE clause.
+ * @param \WP_Query $query Current query.
+ * @return string Filtered WHERE clause.
+ */
+ public function filter_attachments_by_status( $where, $query ) {
+ global $wpdb;
+
+ $status = $query->get( self::STATUS_QUERY_VAR );
+
+ if ( ! is_string( $status ) || ! in_array( $status, array( 'optimized', 'unoptimized', 'skipped', 'failed' ), true ) ) {
+ return $where;
+ }
+
+ $mimes = Helpers::SOURCE_MIME_TYPES;
+ $mime_placeholders = implode( ',', array_fill( 0, count( $mimes ), '%s' ) );
+
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+ $where .= $wpdb->prepare(
+ " AND %i.post_mime_type IN ({$mime_placeholders})",
+ array_merge( array( $wpdb->posts ), $mimes )
+ );
+
+ if ( 'unoptimized' === $status ) {
+ $terminal_statuses = array( Queue::DONE, Queue::SKIPPED, Queue::FAILED );
+ $placeholders = implode( ',', array_fill( 0, count( $terminal_statuses ), '%s' ) );
+
+ return $where . $wpdb->prepare(
+ " AND %i.ID NOT IN (SELECT attachment_id FROM %i WHERE status IN ({$placeholders}))",
+ array_merge( array( $wpdb->posts, Database::get_table() ), $terminal_statuses )
+ );
+ }
+
+ $queue_status = array(
+ 'optimized' => Queue::DONE,
+ 'skipped' => Queue::SKIPPED,
+ 'failed' => Queue::FAILED,
+ )[ $status ];
+
+ return $where . $wpdb->prepare(
+ ' AND %i.ID IN (SELECT attachment_id FROM %i WHERE status = %s)',
+ $wpdb->posts,
+ Database::get_table(),
+ $queue_status
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+ }
+
+ /**
+ * Whether the current user can use the status filter on this screen.
+ *
+ * @since 1.1.0
+ *
+ * @return bool True when the optimization column is visible.
+ */
+ private static function can_view_status_filter(): bool {
+ $screen = get_current_screen();
+
+ return current_user_can( 'upload_files' )
+ && $screen
+ && 'upload' === $screen->id
+ && ! in_array( 'wzio', get_hidden_columns( $screen ), true );
+ }
+
+ /**
+ * Get a valid requested optimization status.
+ *
+ * @since 1.1.0
+ *
+ * @return string Status, or an empty string when none is selected.
+ */
+ private static function get_requested_status(): string {
+ // phpcs:disable WordPress.Security.NonceVerification.Recommended
+ $status = isset( $_GET[ self::STATUS_FILTER ] ) && is_string( $_GET[ self::STATUS_FILTER ] )
+ ? sanitize_key( wp_unslash( $_GET[ self::STATUS_FILTER ] ) )
+ : '';
+ // phpcs:enable WordPress.Security.NonceVerification.Recommended
+
+ return in_array( $status, array( 'optimized', 'unoptimized', 'skipped', 'failed' ), true ) ? $status : '';
+ }
+
/**
* Render the status column.
*
diff --git a/phpunit/tests/MediaLibraryTest.php b/phpunit/tests/MediaLibraryTest.php
new file mode 100644
index 0000000..d6432d2
--- /dev/null
+++ b/phpunit/tests/MediaLibraryTest.php
@@ -0,0 +1,276 @@
+media_library = $reflection->newInstanceWithoutConstructor();
+
+ $this->original_pagenow = $GLOBALS['pagenow'] ?? null;
+ $this->original_main_query = $GLOBALS['wp_the_query'] ?? null;
+
+ $GLOBALS['pagenow'] = 'upload.php';
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+ set_current_screen( 'upload' );
+ }
+
+ /**
+ * Restore globals changed by a test.
+ */
+ public function tear_down() {
+ unset( $_GET['wzio_optimization_status'] );
+ delete_user_option( get_current_user_id(), 'manageuploadcolumnshidden' );
+ Queue::clear();
+
+ if ( null === $this->original_pagenow ) {
+ unset( $GLOBALS['pagenow'] );
+ } else {
+ $GLOBALS['pagenow'] = $this->original_pagenow;
+ }
+
+ $GLOBALS['wp_the_query'] = $this->original_main_query;
+
+ set_current_screen( 'front' );
+ wp_set_current_user( 0 );
+
+ parent::tear_down();
+ }
+
+ /**
+ * The list view renders every status and preserves the selection.
+ */
+ public function test_status_dropdown_renders_on_the_media_list_view() {
+ $_GET['wzio_optimization_status'] = 'failed';
+
+ ob_start();
+ $this->media_library->render_status_filter( 'attachment', 'bar' );
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString( 'name="wzio_optimization_status"', $output );
+ $this->assertStringContainsString( 'All optimization statuses', $output );
+ $this->assertStringContainsString( 'Not yet optimized', $output );
+ $this->assertStringContainsString( 'value="skipped"', $output );
+ $this->assertStringContainsString( 'value="failed" selected=\'selected\'', $output );
+ }
+
+ /**
+ * The dropdown stays hidden when its status column is hidden.
+ */
+ public function test_status_dropdown_requires_a_visible_status_column() {
+ update_user_option( get_current_user_id(), 'manageuploadcolumnshidden', array( 'wzio' ) );
+
+ ob_start();
+ $this->media_library->render_status_filter( 'attachment', 'bar' );
+ $output = ob_get_clean();
+
+ $this->assertSame( '', $output );
+ }
+
+ /**
+ * Every status returns the matching eligible attachments and preserves other filters.
+ */
+ public function test_status_filters_return_exact_attachment_ids() {
+ $attachments = $this->seed_attachments();
+
+ $this->assertSame(
+ array( $attachments['optimized'] ),
+ $this->get_filtered_attachment_ids( 'optimized' )
+ );
+ $this->assertSame(
+ array( $attachments['unoptimized'], $attachments['pending'], $attachments['processing'] ),
+ $this->get_filtered_attachment_ids( 'unoptimized' )
+ );
+ $this->assertSame(
+ array( $attachments['skipped'] ),
+ $this->get_filtered_attachment_ids( 'skipped' )
+ );
+ $this->assertSame(
+ array( $attachments['failed'] ),
+ $this->get_filtered_attachment_ids( 'failed' )
+ );
+ }
+
+ /**
+ * Hiding the status column does not disable an active URL filter.
+ */
+ public function test_hidden_status_column_does_not_disable_filtering() {
+ $attachments = $this->seed_attachments();
+
+ update_user_option( get_current_user_id(), 'manageuploadcolumnshidden', array( 'wzio' ) );
+
+ $this->assertSame(
+ array( $attachments['failed'] ),
+ $this->get_filtered_attachment_ids( 'failed' )
+ );
+ }
+
+ /**
+ * Seed eligible and unsupported attachments across every queue state.
+ *
+ * @return array Attachment IDs keyed by scenario.
+ */
+ private function seed_attachments(): array {
+ $attachments = array(
+ 'optimized' => $this->create_attachment( 'optimized.jpg', 'image/jpeg' ),
+ 'unoptimized' => $this->create_attachment( 'unoptimized.jpg', 'image/jpeg' ),
+ 'skipped' => $this->create_attachment( 'skipped.jpg', 'image/jpeg' ),
+ 'failed' => $this->create_attachment( 'failed.jpg', 'image/jpeg' ),
+ 'pending' => $this->create_attachment( 'pending.jpg', 'image/jpeg' ),
+ 'processing' => $this->create_attachment( 'processing.jpg', 'image/jpeg' ),
+ 'unsupported' => $this->create_attachment( 'unsupported.pdf', 'application/pdf' ),
+ 'optimized_png' => $this->create_attachment( 'optimized.png', 'image/png' ),
+ 'optimized_next_month' => $this->create_attachment( 'optimized-next-month.jpg', 'image/jpeg', '2026-02-15 12:00:00' ),
+ );
+
+ $this->set_queue_status( $attachments['optimized'], Queue::DONE );
+ $this->set_queue_status( $attachments['skipped'], Queue::SKIPPED );
+ $this->set_queue_status( $attachments['failed'], Queue::FAILED );
+ $this->set_queue_status( $attachments['pending'], Queue::PENDING );
+ $this->set_queue_status( $attachments['processing'], Queue::PROCESSING );
+ $this->set_queue_status( $attachments['optimized_png'], Queue::DONE );
+ $this->set_queue_status( $attachments['optimized_next_month'], Queue::DONE );
+
+ return $attachments;
+ }
+
+ /**
+ * Create an attachment fixture.
+ *
+ * @param string $file Fixture filename.
+ * @param string $mime_type Attachment MIME type.
+ * @param string $date Attachment date.
+ * @return int Attachment ID.
+ */
+ private function create_attachment( string $file, string $mime_type, string $date = '2026-01-15 12:00:00' ): int {
+ return (int) self::factory()->attachment->create(
+ array(
+ 'file' => $file,
+ 'post_title' => $file,
+ 'post_mime_type' => $mime_type,
+ 'post_status' => 'inherit',
+ 'post_date' => $date,
+ 'post_date_gmt' => $date,
+ )
+ );
+ }
+
+ /**
+ * Put an attachment into a queue state using the public queue API.
+ *
+ * @param int $attachment_id Attachment ID.
+ * @param string $status Queue status.
+ * @return void
+ */
+ private function set_queue_status( int $attachment_id, string $status ): void {
+ Queue::add( array( $attachment_id ), true );
+
+ if ( Queue::PROCESSING === $status ) {
+ Queue::claim_attachment( $attachment_id );
+ } elseif ( Queue::PENDING !== $status ) {
+ $row_id = Queue::get_id( $attachment_id );
+
+ if ( Queue::FAILED === $status ) {
+ for ( $attempt = 0; $attempt < Queue::MAX_ATTEMPTS; ++$attempt ) {
+ Queue::complete( $row_id, Queue::FAILED );
+ }
+ } else {
+ Queue::complete( $row_id, $status );
+ }
+ }
+
+ $this->assertSame( $status, Queue::get_status( $attachment_id ) );
+ }
+
+ /**
+ * Run a main media query with the requested status and existing MIME/date filters.
+ *
+ * @param string $status Requested status.
+ * @return array Matching attachment IDs.
+ */
+ private function get_filtered_attachment_ids( string $status ): array {
+ $_GET['wzio_optimization_status'] = $status;
+
+ $query = new WP_Query();
+ $query->set( 'post_type', 'attachment' );
+ $query->set( 'post_status', 'inherit' );
+ $query->set( 'post_mime_type', 'image/jpeg' );
+ $query->set( 'm', 202601 );
+ $query->set( 'fields', 'ids' );
+ $query->set( 'orderby', 'ID' );
+ $query->set( 'order', 'ASC' );
+ $query->set( 'posts_per_page', -1 );
+ $query->set( 'no_found_rows', true );
+ $query->set( 'suppress_filters', false );
+
+ $GLOBALS['wp_the_query'] = $query;
+
+ $this->media_library->filter_by_status( $query );
+ add_filter( 'posts_where', array( $this->media_library, 'filter_attachments_by_status' ), 10, 2 );
+
+ try {
+ $attachment_ids = $query->get_posts();
+ } finally {
+ remove_filter( 'posts_where', array( $this->media_library, 'filter_attachments_by_status' ), 10 );
+ }
+
+ return array_map( 'intval', $attachment_ids );
+ }
+}
diff --git a/readme.txt b/readme.txt
index dd21ef0..44a359d 100644
--- a/readme.txt
+++ b/readme.txt
@@ -108,6 +108,11 @@ Yes. Because the format choice happens in the browser rather than on the server,
== Changelog ==
+= 1.1.0 =
+
+* Features:
+ * Filter Media Library images by optimized, not-yet-optimized, skipped and failed status.
+
= 1.0.2 =
Release date: 27 August 2026