--- a/image-optimization/assets/build/admin.asset.php
+++ b/image-optimization/assets/build/admin.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array('react', 'react-dom', 'wp-api-fetch', 'wp-date', 'wp-element', 'wp-i18n', 'wp-url'), 'version' => '96de323e677c46f78c09');
+<?php return array('dependencies' => array('react', 'react-dom', 'wp-api-fetch', 'wp-date', 'wp-element', 'wp-i18n', 'wp-url'), 'version' => 'b474e048f9abeecda04f');
--- a/image-optimization/classes/client/client-response.php
+++ b/image-optimization/classes/client/client-response.php
@@ -41,6 +41,7 @@
$this->known_errors = [
'user reached limit' => new Quota_Exceeded_Error( esc_html__( 'Plan quota reached', 'image-optimization' ) ),
'Bulk token expired' => new Bulk_Token_Expired_Error( esc_html__( 'Bulk token expired', 'image-optimization' ) ),
+ 'bulk request reached limit' => new Bulk_Token_Expired_Error( esc_html__( 'Bulk token expired', 'image-optimization' ) ),
'Image already optimized' => new Image_Already_Optimized_Error( esc_html__( 'Image already optimized', 'image-optimization' ) ),
'Token Auth Guard Request Failed!: Invalid Token' => new Connection_Error( esc_html__( 'Connection error', 'image-optimization' ) ),
];
--- a/image-optimization/classes/client/client.php
+++ b/image-optimization/classes/client/client.php
@@ -146,7 +146,7 @@
$method,
$endpoint,
[
- 'timeout' => 100,
+ 'timeout' => 50,
'headers' => $headers,
'body' => $body,
]
--- a/image-optimization/classes/migration/handlers/cleanup-legacy-bulk-operations.php
+++ b/image-optimization/classes/migration/handlers/cleanup-legacy-bulk-operations.php
@@ -0,0 +1,63 @@
+<?php
+
+namespace ImageOptimizationClassesMigrationHandlers;
+
+use ImageOptimizationClassesAsync_Operation{
+ Async_Operation,
+ Async_Operation_Hook,
+ QueriesImage_Optimization_Operation_Query
+};
+use ImageOptimizationClassesLogger;
+use ImageOptimizationClassesMigrationMigration;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit; // Exit if accessed directly.
+}
+
+class Cleanup_Legacy_Bulk_Operations extends Migration {
+ public static function get_name(): string {
+ return 'cleanup_legacy_bulk_operations';
+ }
+
+ public static function run(): bool {
+ self::cleanup_operations( Async_Operation_Hook::OPTIMIZE_BULK );
+ self::cleanup_operations( Async_Operation_Hook::REOPTIMIZE_BULK );
+
+ return true;
+ }
+
+ private static function cleanup_operations( string $hook ): void {
+ $query = ( new Image_Optimization_Operation_Query() )
+ ->set_hook( $hook )
+ ->set_status( [
+ Async_Operation::OPERATION_STATUS_PENDING,
+ Async_Operation::OPERATION_STATUS_RUNNING,
+ ] )
+ ->set_limit( -1 );
+
+ $operations = Async_Operation::get( $query );
+
+ if ( empty( $operations ) ) {
+ Logger::info( sprintf(
+ 'No legacy operations found for hook %s',
+ $hook
+ ) );
+ return;
+ }
+
+ $operation_ids = array_map(
+ function( $operation ) {
+ return $operation->get_id();
+ },
+ $operations
+ );
+
+ Async_Operation::remove( $operation_ids );
+
+ Logger::info( sprintf(
+ 'Removed %d legacy operations for hook %s',
+ count( $operation_ids ),
+ $hook
+ ) );
+ }
+}
--- a/image-optimization/classes/migration/migration-manager.php
+++ b/image-optimization/classes/migration/migration-manager.php
@@ -11,6 +11,7 @@
use ImageOptimizationClassesLogger;
use ImageOptimizationClassesMigrationHandlers{
+ Cleanup_Legacy_Bulk_Operations,
Fix_Avif_With_Zero_Dimensions,
Fix_Mime_Type,
Fix_Optimized_Size_Keys,
@@ -26,6 +27,7 @@
Fix_Optimized_Size_Keys::class,
Fix_Mime_Type::class,
Fix_Avif_With_Zero_Dimensions::class,
+ Cleanup_Legacy_Bulk_Operations::class,
];
}
--- a/image-optimization/classes/route.php
+++ b/image-optimization/classes/route.php
@@ -357,7 +357,11 @@
}
public function verify_nonce_and_capability( $nonce = '', $name = '', $capability = 'manage_options' ) {
- $this->verify_nonce( $nonce, $name );
+ $valid = $this->verify_nonce( $nonce, $name );
+
+ if ( is_wp_error( $valid ) ) {
+ return $valid;
+ }
if ( ! current_user_can( $capability ) ) {
return $this->respond_error_json([
--- a/image-optimization/image-optimization.php
+++ b/image-optimization/image-optimization.php
@@ -3,7 +3,7 @@
* Plugin Name: Image Optimizer - Compress, Resize and Optimize Images
* Description: Automatically resize, optimize, and convert images to WebP and AVIF. Compress images in bulk or on upload to boost your WordPress site performance.
* Plugin URI: https://go.elementor.com/wp-repo-description-tab-io-product-page/
- * Version: 1.7.1
+ * Version: 1.7.2
* Author: Elementor.com
* Author URI: https://go.elementor.com/author-uri-io/
* Text Domain: image-optimization
@@ -17,7 +17,7 @@
exit; // Exit if accessed directly.
}
-define( 'IMAGE_OPTIMIZATION_VERSION', '1.7.1' );
+define( 'IMAGE_OPTIMIZATION_VERSION', '1.7.2' );
define( 'IMAGE_OPTIMIZATION_FILE', __FILE__ );
define( 'IMAGE_OPTIMIZATION_PATH', plugin_dir_path( IMAGE_OPTIMIZATION_FILE ) );
define( 'IMAGE_OPTIMIZATION_URL', plugins_url( '/', IMAGE_OPTIMIZATION_FILE ) );
@@ -53,7 +53,7 @@
public function __construct() {
require_once IMAGE_OPTIMIZATION_PATH . 'vendor/autoload.php';
-
+
// Init Plugin
add_action( 'plugins_loaded', [ $this, 'init' ], -11 );
}
--- a/image-optimization/modules/backups/rest/remove-backups.php
+++ b/image-optimization/modules/backups/rest/remove-backups.php
@@ -27,11 +27,15 @@
}
public function DELETE( WP_REST_Request $request ) {
- $this->verify_nonce_and_capability(
+ $error = $this->verify_nonce_and_capability(
$request->get_param( self::NONCE_NAME ),
self::NONCE_NAME
);
+ if ( $error ) {
+ return $error;
+ }
+
try {
Remove_All_Backups::find_and_schedule_removing();
--- a/image-optimization/modules/backups/rest/restore-all.php
+++ b/image-optimization/modules/backups/rest/restore-all.php
@@ -27,11 +27,15 @@
}
public function POST( WP_REST_Request $request ) {
- $this->verify_nonce_and_capability(
+ $error = $this->verify_nonce_and_capability(
$request->get_param( self::NONCE_NAME ),
self::NONCE_NAME
);
+ if ( $error ) {
+ return $error;
+ }
+
try {
Restore_Images::find_and_schedule_restoring();
--- a/image-optimization/modules/backups/rest/restore-single.php
+++ b/image-optimization/modules/backups/rest/restore-single.php
@@ -28,11 +28,15 @@
}
public function POST( WP_REST_Request $request ) {
- $this->verify_nonce_and_capability(
+ $error = $this->verify_nonce_and_capability(
$request->get_param( self::NONCE_NAME ),
self::NONCE_NAME
);
+ if ( $error ) {
+ return $error;
+ }
+
$image_id = (int) $request->get_param( 'image_id' );
if ( empty( $image_id ) ) {
--- a/image-optimization/modules/core/components/not-connected.php
+++ b/image-optimization/modules/core/components/not-connected.php
@@ -36,7 +36,7 @@
</b>
<span>
- <a href="<?php echo admin_url( 'admin.php?page=' . ImageOptimizationModulesSettingsModule::SETTING_BASE_SLUG . '&action=connect' ); ?>">
+ <a href="<?php echo esc_url( admin_url( 'admin.php?page=' . ImageOptimizationModulesSettingsModule::SETTING_BASE_SLUG . '&action=connect' ) ); ?>">
<?php esc_html_e(
'Connect now',
'image-optimization'
--- a/image-optimization/modules/core/components/pointers.php
+++ b/image-optimization/modules/core/components/pointers.php
@@ -14,7 +14,9 @@
wp_send_json_error( [ 'message' => 'Invalid nonce' ] );
}
- $pointer = sanitize_text_field( $_POST['data']['pointer'] ) ?? null;
+ $pointer = isset( $_POST['data']['pointer'] ) ?
+ sanitize_text_field( wp_unslash( $_POST['data']['pointer'] ) ) :
+ null;
if ( empty( $pointer ) ) {
wp_send_json_error( [ 'message' => 'The pointer id must be provided' ] );
--- a/image-optimization/modules/core/components/renewal-notice.php
+++ b/image-optimization/modules/core/components/renewal-notice.php
@@ -64,15 +64,15 @@
</svg>
<p>
<b>
- <?php echo $text['title']; ?>
+ <?php echo esc_html( $text['title'] ); ?>
</b>
<span>
- <?php echo $text['description']; ?>
+ <?php echo esc_html( $text['description'] ); ?>
</span>
</p>
</div>
- <a href="<?php echo $text['link']; ?>" target="_blank" rel="noopener noreferrer">
- <?php echo $text['btn']; ?>
+ <a href="<?php echo esc_url( $text['link'] ); ?>" target="_blank" rel="noopener noreferrer">
+ <?php echo esc_html( $text['btn'] ); ?>
</a>
</div>
@@ -80,7 +80,7 @@
jQuery( document ).ready( function( $ ) {
setTimeout(() => {
const $msInOneDay = 24 * 60 * 60 * 1000;
- const $time_dismissed = localStorage.getItem('<?php echo self::RENEWAL_NOTICE_SLUG; ?>');
+ const $time_dismissed = localStorage.getItem('<?php echo esc_js( self::RENEWAL_NOTICE_SLUG ); ?>');
const $show_notice = !$time_dismissed || Date.now() - $time_dismissed >= $msInOneDay;
const $notice = $( '[data-notice-slug="<?php echo esc_js( self::RENEWAL_NOTICE_SLUG ); ?>"]' );
--- a/image-optimization/modules/core/components/user-feedback.php
+++ b/image-optimization/modules/core/components/user-feedback.php
@@ -100,7 +100,7 @@
</b>
<span>
- <?php printf(
+ <?php echo wp_kses_post( sprintf(
__(
'If you enjoyed using Image Optimizer, consider leaving a <a href="%1$s" aria-label="%2$s" target="_blank"
rel="noopener noreferrer">★★★★★</a> review to spread the word.',
@@ -108,7 +108,7 @@
),
esc_url( self::NOTICE_FEEDBACK_LINK ),
esc_attr__( 'Five stars', 'image-optimization' )
- ); ?>
+ ) ); ?>
</span>
</p>
</div>
@@ -172,7 +172,7 @@
</b>
<span>
- <?php printf(
+ <?php echo wp_kses_post( sprintf(
__(
'If you've enjoyed it, consider leaving a <a href="%1$s" aria-label="%2$s" target="_blank"
rel="noopener noreferrer">★★★★★</a> review to spread the word.',
@@ -180,7 +180,7 @@
),
esc_url( self::NOTICE_FEEDBACK_LINK ),
esc_attr__( 'Five stars', 'image-optimization' )
- ); ?>
+ ) ); ?>
</span>
</p>
</div>
@@ -232,14 +232,14 @@
* @return void
*/
public function add_leave_feedback_footer_text(): void {
- printf(
+ echo wp_kses_post( sprintf(
__( '<b>Found Image Optimizer helpful?</b> Leave us a <a href="%1$s" aria-label="%2$s" target="_blank"
rel="noopener noreferrer">★★★★★</a> rating!',
'image-optimization'
),
esc_url( self::FOOTER_FEEDBACK_LINK ),
esc_attr__( 'Five stars', 'image-optimization' )
- );
+ ) );
}
public function __construct() {
--- a/image-optimization/modules/deactivation/module.php
+++ b/image-optimization/modules/deactivation/module.php
@@ -74,7 +74,6 @@
'ajaxurl' => admin_url( 'admin-ajax.php' ),
]
);
-
}
/**
@@ -231,7 +230,7 @@
Logger::log( Logger::LEVEL_ERROR, 'Failed to post feedback: ' . $error_message );
return false;
}
-
+
return true;
}
--- a/image-optimization/modules/oauth/classes/data.php
+++ b/image-optimization/modules/oauth/classes/data.php
@@ -12,7 +12,6 @@
* Class Data
*/
class Data {
-
const CONNECT_CLIENT_DATA_OPTION_NAME = 'image_optimizer_client_data';
const CONNECT_DATA_OPTION_NAME = 'image_optimizer_connect_data';
const OPTION_CONNECT_SITE_KEY = 'image_optimizer_site_key';
@@ -43,7 +42,6 @@
] );
}
-
/**
* get_client_id
* @return string
@@ -69,7 +67,7 @@
*/
public static function get_connect_data( bool $force = false ): array {
static $connect_data = null;
- if ( $connect_data === null || $force ) {
+ if ( null === $connect_data || $force ) {
$connect_data = array_merge(
[
'access_token' => '',
--- a/image-optimization/modules/oauth/classes/route-base.php
+++ b/image-optimization/modules/oauth/classes/route-base.php
@@ -34,18 +34,4 @@
return $valid && user_can( $this->current_user_id, 'manage_options' );
}
-
- public function verify_nonce( $nonce = '', $name = '' ) {
- if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $nonce ) ), $name ) ) {
- wp_die( 'Invalid nonce', 'image-optimization' );
- }
- }
-
- public function verify_nonce_and_capability( $nonce = '', $name = '', $capability = 'manage_options' ) {
- $this->verify_nonce( $nonce, $name );
-
- if ( ! current_user_can( $capability ) ) {
- wp_die( 'You do not have sufficient permissions to access this page.' );
- }
- }
}
--- a/image-optimization/modules/oauth/components/checkpoint.php
+++ b/image-optimization/modules/oauth/components/checkpoint.php
@@ -35,8 +35,8 @@
'POST',
'status/checkpoint',
[
- 'event_name' => $event_name,
- 'event_data' => $event_data,
+ 'event_name' => $event_name,
+ 'event_data' => $event_data,
]
);
}
--- a/image-optimization/modules/oauth/components/connect.php
+++ b/image-optimization/modules/oauth/components/connect.php
@@ -46,7 +46,7 @@
* @return bool
*/
public static function maybe_handle_admin_connect_page(): bool {
- if ( ! isset( $_GET['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['nonce'] ) ), 'nonce_actionget_token' ) ) {
+ if ( ! isset( $_GET['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['nonce'] ) ), 'get_token' ) ) {
return false;
}
@@ -80,7 +80,7 @@
}
// validate nonce
- if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['nonce'] ) ), 'nonce_actionget_token' ) ) {
+ if ( empty( $_GET['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['nonce'] ) ), 'get_token' ) ) {
wp_die( 'Nonce verification failed', 'image-optimization' );
}
@@ -90,12 +90,12 @@
'app' => 'library',
'grant_type' => 'authorization_code',
'client_id' => Data::get_client_id(),
- 'code' => sanitize_text_field( $_GET['code'] ),
+ 'code' => isset( $_GET['code'] ) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : null,
],
] );
if ( is_wp_error( $token_response ) ) {
- wp_die( $token_response->get_error_message(), 'image-optimization' );
+ wp_die( esc_html( $token_response->get_error_message() ), 'image-optimization' );
}
$data = json_decode( wp_remote_retrieve_body( $token_response ), true );
@@ -171,10 +171,10 @@
'page' => 'elementor-connect',
'app' => 'library',
'action' => 'get_token',
- 'nonce' => wp_create_nonce( 'nonce_action' . 'get_token' ),
+ 'nonce' => wp_create_nonce( 'get_token' ),
], admin_url( 'admin.php' ) ) ),
'may_share_data' => 0,
- 'reconnect_nonce' => wp_create_nonce( 'nonce_action' . 'reconnect' ),
+ 'reconnect_nonce' => wp_create_nonce( 'reconnect' ),
], Route_Base::SITE_URL . 'library' );
}
--- a/image-optimization/modules/oauth/rest/activate.php
+++ b/image-optimization/modules/oauth/rest/activate.php
@@ -28,11 +28,15 @@
}
public function POST( WP_REST_Request $request ) {
- $this->verify_nonce_and_capability(
+ $error = $this->verify_nonce_and_capability(
$request->get_param( self::NONCE_NAME ),
self::NONCE_NAME
);
+ if ( $error ) {
+ return $error;
+ }
+
if ( ! Connect::is_connected() ) {
return $this->respond_error_json( [
'message' => esc_html__( 'Please connect first', 'image-optimization' ),
--- a/image-optimization/modules/oauth/rest/connect-init.php
+++ b/image-optimization/modules/oauth/rest/connect-init.php
@@ -28,11 +28,15 @@
}
public function get( WP_REST_Request $request ) {
- $this->verify_nonce_and_capability(
+ $error = $this->verify_nonce_and_capability(
$request->get_param( self::NONCE_NAME ),
self::NONCE_NAME
);
+ if ( $error ) {
+ return $error;
+ }
+
if ( Connect::is_connected() ) {
return $this->respond_error_json( [
'message' => esc_html__( 'You are already connected', 'image-optimization' ),
--- a/image-optimization/modules/oauth/rest/deactivate.php
+++ b/image-optimization/modules/oauth/rest/deactivate.php
@@ -28,11 +28,15 @@
}
public function POST( WP_REST_Request $request ) {
- $this->verify_nonce_and_capability(
+ $error = $this->verify_nonce_and_capability(
$request->get_param( self::NONCE_NAME ),
self::NONCE_NAME
);
+ if ( $error ) {
+ return $error;
+ }
+
if ( ! Connect::is_connected() ) {
return $this->respond_error_json( [
'message' => esc_html__( 'Please connect first', 'image-optimization' ),
--- a/image-optimization/modules/oauth/rest/disconnect.php
+++ b/image-optimization/modules/oauth/rest/disconnect.php
@@ -28,11 +28,15 @@
}
public function POST( WP_REST_Request $request ) {
- $this->verify_nonce_and_capability(
+ $error = $this->verify_nonce_and_capability(
$request->get_param( self::NONCE_NAME ),
self::NONCE_NAME
);
+ if ( $error ) {
+ return $error;
+ }
+
try {
Connect::disconnect();
} catch ( Throwable $t ) {
--- a/image-optimization/modules/oauth/rest/get-subscriptions.php
+++ b/image-optimization/modules/oauth/rest/get-subscriptions.php
@@ -28,11 +28,15 @@
}
public function POST( WP_REST_Request $request ) {
- $this->verify_nonce_and_capability(
+ $error = $this->verify_nonce_and_capability(
$request->get_param( self::NONCE_NAME ),
self::NONCE_NAME
);
+ if ( $error ) {
+ return $error;
+ }
+
if ( ! Connect::is_connected() ) {
return $this->respond_error_json( [
'message' => esc_html__( 'Please connect first', 'image-optimization' ),
--- a/image-optimization/modules/optimization/classes/bulk-optimization-controller.php
+++ b/image-optimization/modules/optimization/classes/bulk-optimization-controller.php
@@ -1,496 +0,0 @@
-<?php
-
-namespace ImageOptimizationModulesOptimizationClasses;
-
-use ImageOptimizationClassesAsync_Operation{
- Async_Operation,
- Async_Operation_Hook,
- Async_Operation_Queue,
- ExceptionsAsync_Operation_Exception,
- QueriesImage_Optimization_Operation_Query
-};
-use ImageOptimizationClassesImage{
- ExceptionsInvalid_Image_Exception,
- Image,
- Image_Meta,
- Image_Optimization_Error_Type,
- Image_Query_Builder,
- Image_Status,
- WP_Image_Meta
-};
-use ImageOptimizationClassesLogger;
-use ImageOptimizationClassesUtils;
-use ImageOptimizationClassesExceptionsQuota_Exceeded_Error;
-use ImageOptimizationModulesOptimizationClassesExceptionsBulk_Token_Obtaining_Error;
-use ImageOptimizationModulesOptimizationComponentsExceptionsBulk_Optimization_Token_Not_Found_Error;
-use ImageOptimizationModulesSettingsClassesSettings;
-use ImageOptimizationModulesStatsClassesOptimization_Stats;
-
-use ImageOptimizationPlugin;
-
-use Throwable;
-
-if ( ! defined( 'ABSPATH' ) ) {
- exit; // Exit if accessed directly.
-}
-
-class Bulk_Optimization_Controller {
- private const OBTAIN_TOKEN_ENDPOINT = 'image/bulk-token';
-
- public static function reschedule_bulk_optimization() {
- self::delete_bulk_optimization();
- self::find_images_and_schedule_optimization();
- }
-
- public static function reschedule_bulk_reoptimization() {
- self::delete_bulk_reoptimization();
- self::find_optimized_images_and_schedule_reoptimization();
- }
-
- /**
- * Cancels pending bulk optimization operations.
- *
- * @return void
- * @throws Async_Operation_Exception
- */
- public static function delete_bulk_optimization(): void {
- $query = ( new Image_Optimization_Operation_Query() )
- ->set_hook( Async_Operation_Hook::OPTIMIZE_BULK )
- // It's risky to cancel in-progress operations at that point, so we cancel only the pending ones.
- ->set_status( Async_Operation::OPERATION_STATUS_PENDING )
- ->set_limit( -1 );
-
- $operations = Async_Operation::get( $query );
-
- foreach ( $operations as $operation ) {
- $image_id = $operation->get_args()['attachment_id'];
-
- Async_Operation::remove( [ $operation->get_id() ] );
-
- ( new Image_Meta( $image_id ) )->delete();
- }
- }
-
- /**
- * Cancels pending bulk re-optimization operations.
- *
- * @return void
- * @throws Async_Operation_Exception
- */
- public static function delete_bulk_reoptimization(): void {
- $query = ( new Image_Optimization_Operation_Query() )
- ->set_hook( Async_Operation_Hook::REOPTIMIZE_BULK )
- // It's risky to cancel in-progress operations at that point, so we cancel only the pending ones.
- ->set_status( Async_Operation::OPERATION_STATUS_PENDING )
- ->set_limit( -1 );
-
- $operations = Async_Operation::get( $query );
-
- foreach ( $operations as $operation ) {
- $image_id = $operation->get_args()['attachment_id'];
-
- Async_Operation::remove( [ $operation->get_id() ] );
-
- ( new Image_Meta( $image_id ) )->delete();
- }
- }
-
- /**
- * Looks for all non-optimized images and creates a bulk operation for each of them.
- * Also, obtains bulk token and passes it to a newly created operation.
- *
- * @return void
- *
- * @throws Quota_Exceeded_Error|Invalid_Image_Exception
- */
- public static function find_images_and_schedule_optimization(): void {
- $images = self::find_images(
- ( new Image_Query_Builder() )
- ->return_not_optimized_images(),
- true
- );
-
- if ( ! $images['total_images_count'] ) {
- $not_fully_optimized_images = self::find_images(
- self::query_not_fully_optimized_images(),
- true
- );
-
- if ( ! $not_fully_optimized_images['total_images_count'] ) {
- return;
- }
-
- $images = $not_fully_optimized_images;
- }
-
- $operation_id = wp_generate_password( 10, false );
-
- try {
- $bulk_token = self::obtain_bulk_token( $images['total_images_count'] );
- self::set_bulk_operation_token( $operation_id, $bulk_token );
- } catch ( Bulk_Token_Obtaining_Error $e ) {
- $bulk_token = null;
- throw new Quota_Exceeded_Error( __( 'Images quota exceeded', 'image-optimization' ) );
- }
-
- foreach ( $images['attachments_in_quota'] as $attachment_id ) {
- $meta = new Image_Meta( $attachment_id );
-
- if ( null === $bulk_token ) {
- $meta
- ->set_status( Image_Status::OPTIMIZATION_FAILED )
- ->save();
-
- continue;
- }
-
- try {
- Async_Operation::create(
- Async_Operation_Hook::OPTIMIZE_BULK,
- [
- 'attachment_id' => $attachment_id,
- 'operation_id' => $operation_id,
- ],
- Async_Operation_Queue::OPTIMIZE
- );
-
- $meta
- ->set_status( Image_Status::OPTIMIZATION_IN_PROGRESS )
- ->save();
- } catch ( Async_Operation_Exception $aoe ) {
- $meta
- ->set_status( Image_Status::OPTIMIZATION_FAILED )
- ->save();
-
- continue;
- }
- }
- }
-
- /**
- * Looks for already optimized images with backups and creates a bulk operation for each of them.
- * Also, obtains bulk token and passes it to a newly created operation.
- *
- * @return void
- *
- * @throws Quota_Exceeded_Error|Invalid_Image_Exception
- */
- public static function find_optimized_images_and_schedule_reoptimization(): void {
- $images = self::find_images(
- ( new Image_Query_Builder() )
- ->return_optimized_images()
- );
-
- if ( ! $images['total_images_count'] ) {
- return;
- }
-
- $operation_id = wp_generate_password( 10, false );
-
- try {
- $bulk_token = self::obtain_bulk_token( $images['total_images_count'] );
- self::set_bulk_operation_token( $operation_id, $bulk_token );
- } catch ( Bulk_Token_Obtaining_Error $e ) {
- $bulk_token = null;
- }
-
- foreach ( $images['attachments_in_quota'] as $attachment_id ) {
- $meta = new Image_Meta( $attachment_id );
-
- if ( null === $bulk_token ) {
- $meta
- ->set_status( Image_Status::REOPTIMIZING_FAILED )
- ->save();
-
- continue;
- }
-
- try {
- Async_Operation::create(
- Async_Operation_Hook::REOPTIMIZE_BULK,
- [
- 'attachment_id' => $attachment_id,
- 'operation_id' => $operation_id,
- ],
- Async_Operation_Queue::OPTIMIZE
- );
-
- $meta
- ->set_status( Image_Status::REOPTIMIZING_IN_PROGRESS )
- ->save();
- } catch ( Async_Operation_Exception $aoe ) {
- $meta
- ->set_status( Image_Status::REOPTIMIZING_FAILED )
- ->save();
-
- continue;
- }
- }
-
- foreach ( $images['attachments_out_of_quota'] as $attachment_id ) {
- ( new Image_Meta( $attachment_id ) )
- ->set_status( Image_Status::REOPTIMIZING_FAILED )
- ->set_error_type( Image_Optimization_Error_Type::QUOTA_EXCEEDED )
- ->save();
- }
- }
-
- /**
- * Looks for images for bulk optimization operations based on a query passed and the quota left.
- *
- * @param Image_Query_Builder $query Image query to execute.
- * @param bool $limit_to_quota If true, it limits image query to the quota left.
- * @return array{total_images_count: int, attachments_in_quota: array, attachments_out_of_quota: array}
- *
- * @throws Invalid_Image_Exception
- * @throws Quota_Exceeded_Error
- */
- private static function find_images( Image_Query_Builder $query, bool $limit_to_quota = false ): array {
- $output = [
- 'total_images_count' => 0,
- 'attachments_in_quota' => [],
- 'attachments_out_of_quota' => [],
- ];
-
- $images_left = Plugin::instance()->modules_manager->get_modules( 'connect-manager' )->connect_instance->images_left();
-
- if ( ! $images_left ) {
- //throw new Quota_Exceeded_Error( __( 'Images quota exceeded', 'image-optimization' ) );
- }
-
- if ( $limit_to_quota ) {
- //$query->set_paging_size( $images_left );
- }
-
- $wp_query = $query->execute();
-
- if ( ! $wp_query->post_count ) {
- return $output;
- }
-
- foreach ( $wp_query->posts as $attachment_id ) {
- try {
- Validate_Image::is_valid( $attachment_id );
- $wp_meta = new WP_Image_Meta( $attachment_id );
- } catch ( Invalid_Image_Exception | ExceptionsImage_Validation_Error $ie ) {
- continue;
- }
-
- $sizes_count = count( $wp_meta->get_size_keys() );
-
- $output['total_images_count'] += $sizes_count;
- $output['attachments_in_quota'][] = $attachment_id;
- }
-
- $output['attachments_out_of_quota'] = array_diff( $wp_query->posts, $output['attachments_in_quota'] );
-
- return $output;
- }
-
- /**
- * Looks for images that were optimized, but not all their sizes were processed.
- *
- * @return Image_Query_Builder
- */
- private static function query_not_fully_optimized_images(): Image_Query_Builder {
- $result = [];
- $sizes_enabled = Settings::get( Settings::CUSTOM_SIZES_OPTION_NAME );
- $optimized_images = ( new Image_Query_Builder() )
- ->return_optimized_images()
- ->execute();
-
- foreach ( $optimized_images->posts as $attachment_id ) {
- try {
- $image_meta = new Image_Meta( $attachment_id );
- $wp_meta = new WP_Image_Meta( $attachment_id );
- } catch ( Invalid_Image_Exception $iie ) {
- continue;
- }
-
- $registered_sizes = $wp_meta->get_size_keys();
- $optimized_sizes = $image_meta->get_optimized_sizes();
-
- if ( 'all' !== $sizes_enabled ) {
- $registered_sizes = array_filter($registered_sizes, function( $size ) use ( $sizes_enabled ) {
- return in_array( $size, $sizes_enabled, true );
- });
-
- $optimized_sizes = array_filter($optimized_sizes, function( $size ) use ( $sizes_enabled ) {
- return in_array( $size, $sizes_enabled, true );
- });
- }
-
- if ( count( $optimized_sizes ) < count( $registered_sizes ) ) {
- $result[] = $attachment_id;
- }
- }
-
- return ( new Image_Query_Builder() )
- ->set_image_ids( $result );
- }
-
- /**
- * Looks for the bulk token in transients.
- *
- * @param string $operation_id Bulk optimization operation id
- *
- * @return string|null Bulk token.
- *
- * @throws Bulk_Optimization_Token_Not_Found_Error
- */
- public static function get_bulk_operation_token( string $operation_id ): ?string {
- $bulk_token = get_transient( "image_optimizer_bulk_token_$operation_id" );
-
- if ( ! $bulk_token ) {
- throw new Bulk_Optimization_Token_Not_Found_Error( "There is no token found for the operation $operation_id" );
- }
-
- return $bulk_token;
- }
-
- /**
- * Saves bulk optimization token to transients for a day.
- *
- * @param string $operation_id Bulk optimization operation id
- * @param string $bulk_token Bulk optimization token
- * @return void
- */
- public static function set_bulk_operation_token( string $operation_id, string $bulk_token ): void {
- set_transient( "image_optimizer_bulk_token_$operation_id", $bulk_token, HOUR_IN_SECONDS );
- }
-
- /**
- * Sends a request to the BE to obtain bulk optimization token.
- * It prevents obtaining a token for each and every optimization operation.
- *
- * @return string
- *
- * @throws Bulk_Token_Obtaining_Error
- */
- private static function obtain_bulk_token( int $images_count ): ?string {
- try {
- $response = Utils::get_api_client()->make_request(
- 'POST',
- self::OBTAIN_TOKEN_ENDPOINT,
- [
- 'images_count' => $images_count,
- ]
- );
- } catch ( Throwable $t ) {
- Logger::log( Logger::LEVEL_ERROR, 'Error while sending bulk token request: ' . $t->getMessage() );
-
- throw new Bulk_Token_Obtaining_Error( $t->getMessage() );
- }
-
- return $response->token ?? null;
- }
-
- /**
- * Checks if there is a bulk optimization operation in progress.
- * If there is at least a single active bulk optimization operation it returns true, otherwise false.
- *
- * @return bool
- * @throws Async_Operation_Exception
- */
- public static function is_optimization_in_progress(): bool {
- $query = ( new Image_Optimization_Operation_Query() )
- ->set_hook( Async_Operation_Hook::OPTIMIZE_BULK )
- ->set_status( [ Async_Operation::OPERATION_STATUS_PENDING, Async_Operation::OPERATION_STATUS_RUNNING ] )
- ->set_limit( 1 )
- ->return_ids();
-
- return ! empty( Async_Operation::get( $query ) );
- }
-
- /**
- * Checks if there is a bulk re-optimization operation in progress.
- * If there is at least a single active bulk re-optimization operation it returns true, otherwise false.
- *
- * @return bool
- * @throws Async_Operation_Exception
- */
- public static function is_reoptimization_in_progress(): bool {
- $query = ( new Image_Optimization_Operation_Query() )
- ->set_hook( Async_Operation_Hook::REOPTIMIZE_BULK )
- ->set_status( [ Async_Operation::OPERATION_STATUS_PENDING, Async_Operation::OPERATION_STATUS_RUNNING ] )
- ->set_limit( 1 )
- ->return_ids();
-
- return ! empty( Async_Operation::get( $query ) );
- }
-
- /**
- * Retrieves the bulk optimization process status.
- *
- * @return array{status: string, stats: array}
- * @throws Async_Operation_Exception
- */
- public static function get_status(): array {
- $stats = Optimization_Stats::get_image_stats();
-
- $output = [
- 'status' => 'not-started',
- 'percentage' => round( $stats['optimized_image_count'] / $stats['total_image_count'] * 100 ),
- ];
-
- $active_query = ( new Image_Optimization_Operation_Query() )
- ->set_hook( Async_Operation_Hook::OPTIMIZE_BULK )
- ->set_status( [ Async_Operation::OPERATION_STATUS_PENDING, Async_Operation::OPERATION_STATUS_RUNNING ] )
- ->set_limit( -1 );
-
- if ( empty( Async_Operation::get( $active_query ) ) ) {
- return $output;
- }
-
- $output['status'] = 'in-progress';
-
- return $output;
- }
-
- /**
- * Returns latest operations for the bulk optimization screen.
- *
- * @param string|null $operation_id
- *
- * @return array
- * @throws Async_Operation_Exception
- */
- public static function get_processed_images( string $operation_id ): array {
- $output = [];
-
- $query = ( new Image_Optimization_Operation_Query() )
- ->set_hook( Async_Operation_Hook::OPTIMIZE_BULK )
- ->set_bulk_operation_id( $operation_id )
- ->set_limit( 50 );
-
- $operations = Async_Operation::get( $query );
-
- foreach ( $operations as $operation ) {
- $image_id = $operation->get_args()['attachment_id'];
- $image = new Image( $image_id );
-
- try {
- $stats = Optimization_Stats::get_image_stats( $image_id );
- } catch ( Invalid_Image_Exception $iie ) {
- continue;
- } catch ( Throwable $t ) {
- $original_file_size = 0;
- $current_file_size = 0;
- }
-
- $output[] = [
- 'id' => $operation->get_id(),
- 'status' => $operation->get_status() === Async_Operation::OPERATION_STATUS_COMPLETE
- ? ( new Image_Meta( $image_id ) )->get_status()
- : $operation->get_status(),
- 'image_name' => $image->get_attachment_object()->post_title,
- 'image_id' => $image_id,
- 'thumbnail_url' => $image->get_url( 'thumbnail' ),
- 'original_file_size' => $stats['initial_image_size'],
- 'current_file_size' => $stats['current_image_size'],
- ];
- }
-
- return $output;
- }
-}
--- a/image-optimization/modules/optimization/classes/bulk-optimization/bulk-optimization-controller.php
+++ b/image-optimization/modules/optimization/classes/bulk-optimization/bulk-optimization-controller.php
@@ -0,0 +1,278 @@
+<?php
+
+namespace ImageOptimizationModulesOptimizationClassesBulk_Optimization;
+
+use ImageOptimizationClassesAsync_Operation{
+ Async_Operation,
+ Async_Operation_Hook,
+ Async_Operation_Queue,
+ ExceptionsAsync_Operation_Exception,
+ QueriesImage_Optimization_Operation_Query
+};
+use ImageOptimizationClassesImage{
+ ExceptionsInvalid_Image_Exception,
+ Image,
+ Image_Meta,
+ Image_Optimization_Error_Type,
+ Image_Query_Builder,
+ Image_Status
+};
+use ImageOptimizationClassesExceptionsQuota_Exceeded_Error;
+use ImageOptimizationClassesLogger;
+use ImageOptimizationModulesStatsClassesOptimization_Stats;
+use Throwable;
+
+// @codeCoverageIgnoreStart
+if ( ! defined( 'ABSPATH' ) ) {
+ exit; // Exit if accessed directly.
+}
+// @codeCoverageIgnoreEnd
+
+final class Bulk_Optimization_Controller {
+ /**
+ * Cancels pending bulk optimization operations.
+ *
+ * @return void
+ * @throws Async_Operation_Exception
+ */
+ public static function delete_bulk_optimization(): void {
+ $queue = new Bulk_Optimization_Queue( Bulk_Optimization_Queue_Type::OPTIMIZATION );
+
+ if ( $queue->exists() ) {
+ foreach ( $queue->get_image_ids() as $image_id ) {
+ $meta = new Image_Meta( $image_id );
+ $status = $meta->get_status();
+
+ if ( Image_Status::OPTIMIZATION_IN_PROGRESS === $status ) {
+ $meta->delete();
+ }
+ }
+
+ $queue->delete();
+ }
+
+ $query = ( new Image_Optimization_Operation_Query() )
+ ->set_hook( Async_Operation_Hook::OPTIMIZE_BULK )
+ ->set_status( [ Async_Operation::OPERATION_STATUS_PENDING, Async_Operation::OPERATION_STATUS_RUNNING ] )
+ ->set_limit( 1 );
+
+ $operation = Async_Operation::get( $query );
+
+ if ( ! empty( $operation ) ) {
+ Async_Operation::remove( [ $operation[0]->get_id() ] );
+ }
+ }
+
+ /**
+ * Creates a queue for bulk optimization and schedules the processor.
+ *
+ * @return void
+ * @throws Quota_Exceeded_Error|Invalid_Image_Exception|Async_Operation_Exception
+ */
+ public static function create_optimization_queue(): void {
+ $images = Bulk_Optimization_Image_Query::find_images(
+ ( new Image_Query_Builder() )
+ ->return_not_optimized_images(),
+ true
+ );
+
+ Logger::debug( 'Non-optimized images found: ' . $images['total_images_count'] );
+
+ if ( ! $images['total_images_count'] ) {
+ $not_fully_optimized_images = Bulk_Optimization_Image_Query::find_images(
+ Bulk_Optimization_Image_Query::query_not_fully_optimized_images(),
+ true
+ );
+
+ Logger::debug( 'Non fully optimized images found: ' . $images['total_images_count'] );
+
+ if ( ! $not_fully_optimized_images['total_images_count'] ) {
+ Logger::debug( 'Bulk optimization not started' );
+
+ return;
+ }
+
+ $images = $not_fully_optimized_images;
+ }
+
+ $token_data = Bulk_Optimization_Token_Manager::obtain_token( $images['total_images_count'] );
+ $queue = new Bulk_Optimization_Queue( Bulk_Optimization_Queue_Type::OPTIMIZATION );
+
+ Logger::debug( "Bulk token obtained for {$token_data['batch_size']} images" );
+
+ $queue
+ ->set_bulk_token(
+ $token_data['token'],
+ time() + HOUR_IN_SECONDS,
+ $token_data['batch_size']
+ )
+ ->set_status( Bulk_Optimization_Queue_Status::PROCESSING )
+ ->add_images( $images['attachments_in_quota'] )
+ ->save();
+
+ Logger::info( "New queue {$queue->get_operation_id()} created" );
+
+ Async_Operation::create(
+ Async_Operation_Hook::OPTIMIZE_BULK,
+ [ 'operation_id' => $queue->get_operation_id() ],
+ Async_Operation_Queue::OPTIMIZE
+ );
+
+ Logger::debug( 'Async operation created' );
+
+ foreach ( $images['attachments_in_quota'] as $image_id ) {
+ ( new Image_Meta( $image_id ) )
+ ->set_status( Image_Status::OPTIMIZATION_IN_PROGRESS )
+ ->set_retry_count( 0 )
+ ->set_error_type( null )
+ ->save();
+ }
+ }
+
+ /**
+ * Creates a queue for bulk reoptimization and schedules the processor.
+ *
+ * @return void
+ * @throws Quota_Exceeded_Error|Invalid_Image_Exception|Async_Operation_Exception
+ */
+ public static function create_reoptimization_queue(): void {
+ $images = Bulk_Optimization_Image_Query::find_images(
+ ( new Image_Query_Builder() )
+ ->return_optimized_images()
+ );
+
+ if ( ! $images['total_images_count'] ) {
+ return;
+ }
+
+ $token_data = Bulk_Optimization_Token_Manager::obtain_token( $images['total_images_count'] );
+ $queue = new Bulk_Optimization_Queue( Bulk_Optimization_Queue_Type::REOPTIMIZATION );
+
+ $queue
+ ->set_bulk_token(
+ $token_data['token'],
+ time() + HOUR_IN_SECONDS,
+ $token_data['batch_size']
+ )
+ ->set_status( Bulk_Optimization_Queue_Status::PROCESSING )
+ ->add_images( $images['attachments_in_quota'] )
+ ->save();
+
+ Async_Operation::create(
+ Async_Operation_Hook::REOPTIMIZE_BULK,
+ [ 'operation_id' => $queue->get_operation_id() ],
+ Async_Operation_Queue::OPTIMIZE
+ );
+
+ foreach ( $images['attachments_in_quota'] as $image_id ) {
+ ( new Image_Meta( $image_id ) )
+ ->set_status( Image_Status::REOPTIMIZING_IN_PROGRESS )
+ ->set_retry_count( 0 )
+ ->set_error_type( null )
+ ->save();
+ }
+
+ // Handle images out of quota
+ foreach ( $images['attachments_out_of_quota'] as $image_id ) {
+ ( new Image_Meta( $image_id ) )
+ ->set_status( Image_Status::REOPTIMIZING_FAILED )
+ ->set_error_type( Image_Optimization_Error_Type::QUOTA_EXCEEDED )
+ ->save();
+ }
+ }
+
+ /**
+ * Checks if there is a bulk optimization operation in progress.
+ * If there is at least a single active bulk optimization operation it returns true, otherwise false.
+ *
+ * @return bool
+ */
+ public static function is_optimization_in_progress(): bool {
+ $queue = new Bulk_Optimization_Queue( Bulk_Optimization_Queue_Type::OPTIMIZATION );
+
+ return $queue->exists() && Bulk_Optimization_Queue_Status::PROCESSING === $queue->get_status();
+ }
+
+ /**
+ * Checks if there is a bulk re-optimization operation in progress.
+ * If there is at least a single active bulk re-optimization operation it returns true, otherwise false.
+ *
+ * @return bool
+ */
+ public static function is_reoptimization_in_progress(): bool {
+ $queue = new Bulk_Optimization_Queue( Bulk_Optimization_Queue_Type::REOPTIMIZATION );
+
+ return $queue->exists() && Bulk_Optimization_Queue_Status::PROCESSING === $queue->get_status();
+ }
+
+ /**
+ * Retrieves the bulk optimization process status.
+ *
+ * @return array{status: string, stats: array}
+ */
+ public static function get_status(): array {
+ $stats = Optimization_Stats::get_image_stats();
+
+ $percentage = 0;
+ if ( $stats['total_image_count'] > 0 ) {
+ $percentage = round( $stats['optimized_image_count'] / $stats['total_image_count'] * 100 );
+ }
+
+ $output = [
+ 'status' => 'not-started',
+ 'percentage' => $percentage,
+ ];
+
+ $queue = new Bulk_Optimization_Queue( Bulk_Optimization_Queue_Type::OPTIMIZATION );
+
+ if ( ! $queue->exists() ) {
+ return $output;
+ }
+
+ $output['status'] = 'in-progress';
+
+ return $output;
+ }
+
+ /**
+ * Returns latest operations for the bulk optimization screen.
+ *
+ * @return array
+ */
+ public static function get_processed_images(): array {
+ $output = [];
+ $queue = new Bulk_Optimization_Queue( Bulk_Optimization_Queue_Type::OPTIMIZATION );
+
+ if ( ! $queue->exists() ) {
+ return $output;
+ }
+
+ $images = $queue->get_images();
+ $images = array_slice( $images, 0, 50 );
+
+ foreach ( $images as $queue_image ) {
+ $image_id = $queue_image['id'];
+
+ try {
+ $image = new Image( $image_id );
+ $stats = Optimization_Stats::get_image_stats( $image_id );
+ } catch ( Throwable $t ) {
+ continue;
+ }
+
+ $meta = new Image_Meta( $image_id );
+
+ $output[] = [
+ 'id' => $image_id,
+ 'status' => $meta->get_status(),
+ 'image_name' => $image->get_attachment_object()->post_title,
+ 'image_id' => $image_id,
+ 'thumbnail_url' => $image->get_url( 'thumbnail' ),
+ 'original_file_size' => $stats['initial_image_size'],
+ 'current_file_size' => $stats['current_image_size'],
+ ];
+ }
+
+ return $output;
+ }
+}
--- a/image-optimization/modules/optimization/classes/bulk-optimization/bulk-optimization-image-query.php
+++ b/image-optimization/modules/optimization/classes/bulk-optimization/bulk-optimization-image-query.php
@@ -0,0 +1,133 @@
+<?php
+
+namespace ImageOptimizationModulesOptimizationClassesBulk_Optimization;
+
+use ImageOptimizationClassesImage{
+ Image_Meta,
+ Image_Query_Builder,
+ ExceptionsInvalid_Image_Exception,
+ WP_Image_Meta,
+};
+
+use ImageOptimizationClassesExceptionsQuota_Exceeded_Error;
+use ImageOptimizationModulesCoreModule as Core_Module;
+use ImageOptimizationModulesOptimizationClasses{
+ Validate_Image,
+ ExceptionsImage_Validation_Error
+};
+use ImageOptimizationModulesSettingsClassesSettings;
+use ImageOptimizationPlugin;
+
+// @codeCoverageIgnoreStart
+if ( ! defined( 'ABSPATH' ) ) {
+ exit; // Exit if accessed directly.
+}
+// @codeCoverageIgnoreEnd
+
+final class Bulk_Optimization_Image_Query {
+ /**
+ * Looks for images for bulk optimization operations based on a query passed and the quota left.
+ *
+ * @param Image_Query_Builder $query Image query to execute.
+ * @param bool $limit_to_quota If true, it limits image query to the quota left.
+ * @return array{total_images_count: int, attachments_in_quota: array, attachments_out_of_quota: array}
+ *
+ * @throws Invalid_Image_Exception
+ * @throws Quota_Exceeded_Error
+ */
+ public static function find_images( Image_Query_Builder $query, bool $limit_to_quota = false ): array {
+ $output = [
+ 'total_images_count' => 0,
+ 'attachments_in_quota' => [],
+ 'attachments_out_of_quota' => [],
+ ];
+
+ if ( ! Core_Module::is_elementor_one() ) {
+ $images_left = Plugin::instance()->modules_manager->get_modules( 'connect-manager' )->connect_instance->images_left();
+
+ if ( ! $images_left ) {
+ throw new Quota_Exceeded_Error( __( 'Images quota exceeded', 'image-optimization' ) );
+ }
+
+ if ( $limit_to_quota ) {
+ $query->set_paging_size( $images_left );
+ }
+ }
+
+ $wp_query = $query->execute();
+
+ if ( ! $wp_query->post_count ) {
+ return $output;
+ }
+
+ foreach ( $wp_query->posts as $attachment_id ) {
+ try {
+ Validate_Image::is_valid( $attachment_id );
+ $wp_meta = new WP_Image_Meta( $attachment_id );
+ } catch ( Invalid_Image_Exception | Image_Validation_Error $ie ) {
+ continue;
+ }
+
+ $sizes_count = count( $wp_meta->get_size_keys() );
+
+ if ( ! Core_Module::is_elementor_one() ) {
+ if ( $output['total_images_count'] + $sizes_count <= $images_left ) {
+ $output['total_images_count'] += $sizes_count;
+
+ $output['attachments_in_quota'][] = $attachment_id;
+ } else {
+ break;
+ }
+ } else {
+ $output['total_images_count'] += $sizes_count;
+ $output['attachments_in_quota'][] = $attachment_id;
+ }
+ }
+
+ $output['attachments_out_of_quota'] = array_diff( $wp_query->posts, $output['attachments_in_quota'] );
+
+ return $output;
+ }
+
+ /**
+ * Looks for images that were optimized, but not all their sizes were processed.
+ *
+ * @return Image_Query_Builder
+ */
+ public static function query_not_fully_optimized_images(): Image_Query_Builder {
+ $result = [];
+ $sizes_enabled = Settings::get( Settings::CUSTOM_SIZES_OPTION_NAME );
+ $optimized_images = ( new Image_Query_Builder() )
+ ->return_optimized_images()
+ ->execute();
+
+ foreach ( $optimized_images->posts as $attachment_id ) {
+ try {
+ $image_meta = new Image_Meta( $attachment_id );
+ $wp_meta = new WP_Image_Meta( $attachment_id );
+ } catch ( Invalid_Image_Exception $iie ) {
+ continue;
+ }
+
+ $registered_sizes = $wp_meta->get_size_keys();
+ $optimized_sizes = $image_meta->get_optimized_sizes();
+
+ if ( 'all' !== $sizes_enabled ) {
+ $registered_sizes = array_filter( $registered_sizes, function( $size ) use ( $sizes_enabled ) {
+ return in_array( $size, $sizes_enabled, true );
+ } );
+
+ $optimized_sizes = array_filter( $optimized_sizes, function( $size ) use ( $sizes_enabled ) {
+ return in_array( $size, $sizes_enabled, true );
+ } );
+ }
+
+ if ( count( $optimized_sizes ) < count( $registered_sizes ) ) {
+ $result[] = $attachment_id;
+ }
+ }
+
+ return ( new Image_Query_Builder() )
+ ->set_image_ids( $result );
+ }
+}
--- a/image-optimization/modules/optimization/classes/bulk-optimization/bulk-optimization-queue-status.php
+++ b/image-optimization/modules/optimization/classes/bulk-optimization/bulk-optimization-queue-status.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace ImageOptimizationModulesOptimizationClassesBulk_Optimization;
+
+use ImageOptimizationClassesBasic_Enum;
+
+// @codeCoverageIgnoreStart
+if ( ! defined( 'ABSPATH' ) ) {
+ exit; // Exit if accessed directly.
+}
+// @codeCoverageIgnoreEnd
+
+final class Bulk_Optimization_Queue_Status extends Basic_Enum {
+ public const PENDING = 'pending';
+ public const PROCESSING = 'processing';
+ public const COMPLETED = 'completed';
+ public const FAILED = 'failed';
+ public const CANCELLED = 'cancelled';
+}
--- a/image-optimization/modules/optimization/classes/bulk-optimization/bulk-optimization-queue-type.php
+++ b/image-optimization/modules/optimization/classes/bulk-optimization/bulk-optimization-queue-type.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace ImageOptimizationModulesOptimizationClassesBulk_Optimization;
+
+use ImageOptimizationClassesBasic_Enum;
+
+// @codeCoverageIgnoreStart
+if ( ! defined( 'ABSPATH' ) ) {
+ exit; // Exit if accessed directly.
+}
+// @codeCoverageIgnoreEnd
+
+final class Bulk_Optimization_Queue_Type extends Basic_Enum {
+ public const OPTIMIZATION = 'optimization';
+ public const REOPTIMIZATION = 'reoptimization';
+}
--- a/image-optimization/modules/optimization/classes/bulk-optimization/bulk-optimization-queue.php
+++ b/image-optimization/modules/optimization/classes/bulk-optimization/bulk-optimization-queue.php
@@ -0,0 +1,407 @@
+<?php
+
+namespace ImageOptimizationModulesOptimizationClassesBulk_Optimization;
+
+use ImageOptimizationClassesAsync_Operation{
+ Async_Operation,
+ Async_Operation_Hook,
+ ExceptionsAsync_Operation_Exception,
+ QueriesImage_Optimization_Operation_Query,
+};
+
+use ImageOptimizationClassesImage{
+ Image_Meta,
+ Image_Optimization_Error_Type,
+ Image_Status,
+ WP_Image_Meta,
+ ExceptionsInvalid_Image_Exception,
+};
+
+use ImageOptimizationClassesLogger;
+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( "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( "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( "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;
+ }
+}
--- a/image-optimization/modules/optimization/classes/bulk-optimization/bulk-optimization-token-manager.php
+++ b/image-optimization/modules/optimization/classes/bulk-optimization/bulk-optimization-token-manager.php
@@ -0,0 +1,93 @@
+<?php
+
+namespace ImageOptimizationModulesOptimizationClassesBulk_Optimization;
+
+use ImageOptimizationClassesExceptionsQuota_Exceeded_Error;
+use ImageOptimizationClassesLogger;
+use ImageOptimizationClassesUtils;
+use ImageOptimizationModulesOptimizationClassesExceptionsBulk_Token_Obtaining_Error;
+use Throwable;
+
+// @codeCoverageIgnoreStart
+if ( ! defined( 'ABSPATH' ) ) {
+ exit; // Exit if accessed directly.
+}
+// @codeCoverageIgnoreEnd
+
+final class Bulk_Optimization_Token_Manager {
+ private const OBTAIN_TOKEN_ENDPOINT = 'image/bulk-token';
+
+ /**
+ * Sends a request to the BE to obtain bulk optimization token.
+ * It prevents obtaining a token for each and every optimization operation.
+ *
+ * @param int $images_count Total number of images to optimize.
+ * @param int|null $max_batch_size Maximum batch size to try (from previous successful attempt).
+ * @return array
+ *
+ * @throws Quota_Exceeded_Error
+ */
+ public static function obtain_token( int $images_count, int $max_batch_size = null ): array {
+ $base_sequence = [ $images_count, intval( $images_count / 2 ), 100, 50, 25, 10, 5, 1 ];
+
+ if ( null !== $max_batch_size ) {
+ $base_sequence = array_filter( $base_sequence, function( $size ) use ( $max_batch_size ) {
+ return $size <= $max_batch_size;
+ } );
+ }
+
+ $batch_size_sequence = array_unique( $base_sequence, SORT_NUMERIC );
+
+ foreach ( $batch_size_sequence as $batch_size ) {
+ if ( $images_count < $batch_size ) {
+ continue;
+ }
+
+ try {
+ $token = self::request_token_for_count( $batch_size );
+
+ return [
+ 'token' => $token,
+ 'batch_size' => $batch_size,
+ ];
+ } catch ( Quota_Exceeded_Error | Bulk_Token_Obtaining_Error $te ) {
+ Logger::debug( "Quota exceeded for ba