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