• File: bulk-optimization-queue.php
  • Full Path: /home/builxejc/public_html/wp-content/plugins/image-optimization/modules/optimization/classes/bulk-optimization/bulk-optimization-queue.php
  • Date Modified: 02/17/2026 6:59 PM
  • File size: 10.42 KB
  • MIME-type: text/x-php
  • Charset: utf-8
<?php

namespace ImageOptimization\Modules\Optimization\Classes\Bulk_Optimization;

use ImageOptimization\Classes\Async_Operation\{
	Async_Operation,
	Async_Operation_Hook,
	Exceptions\Async_Operation_Exception,
	Queries\Image_Optimization_Operation_Query,
};

use ImageOptimization\Classes\Image\{
	Image_Meta,
	Image_Optimization_Error_Type,
	Image_Status,
	WP_Image_Meta,
	Exceptions\Invalid_Image_Exception,
};

use ImageOptimization\Classes\Logger;
use TypeError;

// @codeCoverageIgnoreStart
if ( ! defined( 'ABSPATH' ) ) {
	exit; // Exit if accessed directly.
}
// @codeCoverageIgnoreEnd

final class Bulk_Optimization_Queue {
	private const OPTION_PREFIX = 'image_optimizer_bulk_queue_';
	private const MAX_RETRIES = 3;

	private const INITIAL_QUEUE_VALUE = [
		'operation_id' => null,
		'type' => null,
		'bulk_token' => null,
		'token_expires_at' => null,
		'max_batch_size' => null, // Maximum batch size that successfully obtained a token
		'images_optimized_with_current_token' => 0, // Counter for current token usage
		'created_at' => null,
		'status' => Bulk_Optimization_Queue_Status::PENDING,
		'images' => [], // Array of ['id' => int, 'status' => 'pending'|'completed'|'failed']
		'stats' => [
			'total' => 0,
			'completed' => 0,
			'failed' => 0,
			'pending' => 0,
		],
		'current_image_id' => null,
	];

	private string $type;
	private array $queue_data;

	public function get_operation_id(): ?string {
		if ( $this->exists() && empty( $this->queue_data['operation_id'] ) ) {
			$this->queue_data['operation_id'] = wp_generate_password( 10, false );
			$this->save();
		}

		return $this->queue_data['operation_id'];
	}

	public function get_type(): string {
		return $this->type;
	}

	public function get_bulk_token(): ?string {
		return $this->queue_data['bulk_token'];
	}

	public function get_status(): string {
		return $this->queue_data['status'];
	}

	public function get_images(): array {
		return $this->queue_data['images'];
	}

	public function get_image_ids(): array {
		return array_column( $this->queue_data['images'], 'id' );
	}

	public function get_images_by_status( string $status ): array {
		if ( ! in_array( $status, Bulk_Optimization_Queue_Status::get_values(), true ) ) {
			Logger::error( "Status $status is not a part of Bulk_Optimization_Queue_Status values" );

			throw new TypeError( esc_html( "Status $status is not a part of Bulk_Optimization_Queue_Status values" ) );
		}

		return array_filter(
			$this->queue_data['images'],
			function ( $image ) use ( $status ) {
				return $image['status'] === $status;
			}
		);
	}

	public function get_stats(): array {
		return $this->queue_data['stats'];
	}

	public function get_current_image_id(): ?int {
		return $this->queue_data['current_image_id'];
	}

	public function set_operation_id( string $id ): self {
		$this->queue_data['operation_id'] = $id;

		return $this;
	}

	public function set_bulk_token( string $token, int $expires_at, int $batch_size = null ): self {
		$this->queue_data['bulk_token'] = $token;
		$this->queue_data['token_expires_at'] = $expires_at;

		// Update max batch size if provided and larger than current
		if ( null !== $batch_size ) {
			if ( null === $this->queue_data['max_batch_size'] || $batch_size > $this->queue_data['max_batch_size'] ) {
				$this->queue_data['max_batch_size'] = $batch_size;
			}
		}

		// Reset counter when new token is set
		$this->queue_data['images_optimized_with_current_token'] = 0;

		return $this;
	}

	public function get_max_batch_size(): ?int {
		return $this->queue_data['max_batch_size'];
	}

	public function increment_optimized_counter(): self {
		$this->queue_data['images_optimized_with_current_token']++;

		return $this;
	}

	public function should_refresh_token(): bool {
		if ( $this->is_token_expiring_soon() ) {
			return true;
		}

		// Check if we've exhausted the current batch quota
		$max_batch = $this->queue_data['max_batch_size'];
		$optimized_count = $this->queue_data['images_optimized_with_current_token'];

		if ( null !== $max_batch && $optimized_count >= $max_batch ) {
			return true;
		}

		// Check if we have enough quota for the next pending image
		if ( null !== $max_batch ) {
			$next_image_id = $this->get_next_image();

			if ( $next_image_id ) {
				try {
					$wp_meta = new WP_Image_Meta( $next_image_id );
					$sizes_count = count( $wp_meta->get_size_keys() );
					$remaining_quota = $max_batch - $optimized_count;

					if ( $sizes_count > $remaining_quota ) {
						return true;
					}
				} catch ( Invalid_Image_Exception $e ) {
					// If we can't get image meta, continue with current token
					return false;
				}
			}
		}

		return false;
	}

	public function set_status( string $status ): self {
		if ( ! in_array( $status, Bulk_Optimization_Queue_Status::get_values(), true ) ) {
			Logger::error( "Status $status is not a part of Bulk_Optimization_Queue_Status values" );

			throw new TypeError( esc_html( "Status $status is not a part of Bulk_Optimization_Queue_Status values" ) );
		}

		$this->queue_data['status'] = $status;

		return $this;
	}

	public function set_current_image_id( ?int $id ): self {
		$this->queue_data['current_image_id'] = $id;

		return $this;
	}

	public function add_images( array $image_ids ): self {
		$existing_ids = array_column( $this->queue_data['images'], 'id' );

		foreach ( $image_ids as $image_id ) {
			if ( in_array( $image_id, $existing_ids, true ) ) {
				continue;
			}

			$this->queue_data['images'][] = [
				'id' => $image_id,
				'status' => Bulk_Optimization_Queue_Status::PENDING,
			];

			$existing_ids[] = $image_id;
		}

		$this->update_stats();

		return $this;
	}

	public function get_next_image(): ?int {
		$pending_images = $this->get_images_by_status( Bulk_Optimization_Queue_Status::PENDING );

		if ( empty( $pending_images ) ) {
			return null;
		}

		$first_image = reset( $pending_images );

		return $first_image['id'];
	}

	public function mark_image_completed( int $image_id ): self {
		foreach ( $this->queue_data['images'] as &$image ) {
			if ( $image['id'] === $image_id ) {
				$image['status'] = Bulk_Optimization_Queue_Status::COMPLETED;
				break;
			}
		}

		unset( $image );

		( new Image_Meta( $image_id ) )
			->set_retry_count( null )
			->save();

		$this->update_stats();

		return $this;
	}

	public function mark_image_failed( int $image_id ): self {
		$meta = new Image_Meta( $image_id );
		$retry_count = $meta->get_retry_count() ?? 0;
		$retry_count++;

		$is_reoptimization = Bulk_Optimization_Queue_Type::REOPTIMIZATION === $this->type;

		// Update Image_Meta with failure and increment retry count
		$meta->set_status(
			$is_reoptimization
				? Image_Status::REOPTIMIZING_FAILED
				: Image_Status::OPTIMIZATION_FAILED
		)
		->set_retry_count( $retry_count )
		->save();

		// Check if we should retry or mark as permanently failed
		if ( $retry_count >= self::MAX_RETRIES ) {
			// Mark as permanently failed in queue
			foreach ( $this->queue_data['images'] as &$image ) {
				if ( $image['id'] === $image_id ) {
					$image['status'] = Bulk_Optimization_Queue_Status::FAILED;
					break;
				}
			}
			unset( $image ); // Break the reference

			$meta
				->set_error_type( Image_Optimization_Error_Type::GENERIC )
				->save();
		}

		$this->update_stats();

		return $this;
	}

	public function is_empty(): bool {
		return empty( $this->queue_data['images'] );
	}

	public function has_more_images(): bool {
		return ! empty( $this->get_images_by_status( Bulk_Optimization_Queue_Status::PENDING ) );
	}

	public function is_token_expired(): bool {
		if ( ! $this->queue_data['token_expires_at'] ) {
			return true;
		}

		return time() >= $this->queue_data['token_expires_at'];
	}

	public function is_token_expiring_soon(): bool {
		$buffer_seconds = 5 * MINUTE_IN_SECONDS;

		if ( ! $this->queue_data['token_expires_at'] ) {
			return true;
		}

		return time() >= ( $this->queue_data['token_expires_at'] - $buffer_seconds );
	}

	public function save(): self {
		update_option( $this->get_option_name(), $this->queue_data, false );

		return $this;
	}

	public function delete(): bool {
		$this->cancel_scheduled_actions();

		return delete_option( $this->get_option_name() );
	}

	/**
	 * Cancels any scheduled actions associated with this queue.
	 */
	private function cancel_scheduled_actions(): void {
		$operation_id = $this->get_operation_id();

		if ( empty( $operation_id ) ) {
			return;
		}

		$hook = Bulk_Optimization_Queue_Type::OPTIMIZATION === $this->type
			? Async_Operation_Hook::OPTIMIZE_BULK
			: Async_Operation_Hook::REOPTIMIZE_BULK;

		$query = ( new Image_Optimization_Operation_Query() )
			->set_hook( $hook )
			->set_bulk_operation_id( $operation_id )
			->return_ids()
			->set_limit( -1 );

		try {
			$operation_ids = Async_Operation::get( $query );
			Async_Operation::remove( $operation_ids );
		} catch ( Async_Operation_Exception $aee ) {
			Logger::error( "Error while removing redundant actions for the operation `{$operation_id}`" );
		}
	}

	public function exists(): bool {
		return false !== get_option( $this->get_option_name(), false );
	}

	public function __construct( string $type ) {
		if ( ! in_array( $type, Bulk_Optimization_Queue_Type::get_values(), true ) ) {
			Logger::error( "Type $type is not a part of Bulk_Optimization_Queue_Type values" );

			throw new TypeError( esc_html( "Type $type is not a part of Bulk_Optimization_Queue_Type values" ) );
		}

		$this->type = $type;
		$this->query_queue();
	}

	private function query_queue(): void {
		$queue = get_option( $this->get_option_name(), false );
		$this->queue_data = $queue
			? array_replace_recursive( self::INITIAL_QUEUE_VALUE, $queue )
			: self::INITIAL_QUEUE_VALUE;

		if ( ! $this->queue_data['type'] ) {
			$this->queue_data['type'] = $this->type;
		}

		if ( ! $this->queue_data['created_at'] ) {
			$this->queue_data['created_at'] = time();
		}
	}

	private function get_option_name(): string {
		return self::OPTION_PREFIX . $this->type;
	}

	private function update_stats(): void {
		$completed = 0;
		$failed = 0;
		$pending = 0;

		foreach ( $this->queue_data['images'] as $image ) {
			switch ( $image['status'] ) {
				case Bulk_Optimization_Queue_Status::COMPLETED:
					$completed++;
					break;
				case Bulk_Optimization_Queue_Status::FAILED:
					$failed++;
					break;
				case Bulk_Optimization_Queue_Status::PENDING:
					$pending++;
					break;
			}
		}

		$this->queue_data['stats']['total'] = count( $this->queue_data['images'] );
		$this->queue_data['stats']['completed'] = $completed;
		$this->queue_data['stats']['failed'] = $failed;
		$this->queue_data['stats']['pending'] = $pending;
	}
}