From 5a2c7b54161b8ed9888b6aa9309e4c02ff0064d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Robin?= Date: Fri, 12 Jun 2026 17:04:28 +0200 Subject: [PATCH 1/6] feat(tools): add one-click Reset Internal State troubleshooting tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 'Reset Internal State' button to the Imagify Settings page that clears stale optimization transients, process locks, and pending ActionScheduler jobs — replicating the deactivation cleanup without touching user settings or media optimization records. New module: classes/Tools/ (InternalStateList, ResetInternalState, Subscriber, ServiceProvider). InternalStateList is autoloader-free so uninstall.php can require_once it before the container is bootstrapped. Multisite: also clears sitemeta process locks via a separate DELETE. All DB queries use $wpdb->prepare + $wpdb->esc_like (no raw SQL). Co-Authored-By: Claude Sonnet 4.6 --- .../Tools/InternalStateList/sharedList.php | 87 ++++++++ .../Tools/ResetInternalState/reset.php | 209 ++++++++++++++++++ .../Tools/Subscriber/getSubscribedEvents.php | 48 ++++ classes/Tools/InternalStateList.php | 79 +++++++ classes/Tools/ResetInternalState.php | 62 ++++++ classes/Tools/ServiceProvider.php | 62 ++++++ classes/Tools/Subscriber.php | 63 ++++++ config/providers.php | 1 + uninstall.php | 21 +- 9 files changed, 619 insertions(+), 13 deletions(-) create mode 100644 Tests/Unit/classes/Tools/InternalStateList/sharedList.php create mode 100644 Tests/Unit/classes/Tools/ResetInternalState/reset.php create mode 100644 Tests/Unit/classes/Tools/Subscriber/getSubscribedEvents.php create mode 100644 classes/Tools/InternalStateList.php create mode 100644 classes/Tools/ResetInternalState.php create mode 100644 classes/Tools/ServiceProvider.php create mode 100644 classes/Tools/Subscriber.php diff --git a/Tests/Unit/classes/Tools/InternalStateList/sharedList.php b/Tests/Unit/classes/Tools/InternalStateList/sharedList.php new file mode 100644 index 00000000..532e5010 --- /dev/null +++ b/Tests/Unit/classes/Tools/InternalStateList/sharedList.php @@ -0,0 +1,87 @@ +assertSame( $expected, InternalStateList::get_bulk_transients() ); + } + + /** + * Asserts that user-data/account transients are NOT present in the bulk list. + */ + public function testGetBulkTransientsDoesNotContainUserCacheTransients(): void { + $user_cache_transients = [ + 'imagify_user', + 'imagify_user_cache', + 'imagify_user_images_count', + 'imagify_large_library', + 'imagify_attachments_number_modal', + 'imagify_stat_without_next_gen', + 'imagify_max_image_size', + 'imagify_check_licence_1', + 'imagify_check_api_version', + 'imagify_check_api_key_validity', + 'imagify_settings', + 'imagify_data', + ]; + + $bulk_transients = InternalStateList::get_bulk_transients(); + + foreach ( $user_cache_transients as $cache_transient ) { + $this->assertNotContains( $cache_transient, $bulk_transients ); + } + } + + /** + * get_locked_transient_patterns() returns the expected canonical array. + */ + public function testGetLockedTransientPatternsReturnsExpectedArray(): void { + $expected = [ + '\_transient\_%imagify-auto-optimize-%', + '\_transient\_%imagify\_rpc\_%', + '\_transient\_imagify\_%\_process\_locked', + '\_site\_transient\_imagify\_%\_process\_lock%', + ]; + + $this->assertSame( $expected, InternalStateList::get_locked_transient_patterns() ); + } + + /** + * get_scheduler_hooks() returns the expected canonical array. + */ + public function testGetSchedulerHooksReturnsExpectedArray(): void { + $expected = [ + 'imagify_optimize_media', + 'imagify_convert_next_gen', + ]; + + $this->assertSame( $expected, InternalStateList::get_scheduler_hooks() ); + } +} diff --git a/Tests/Unit/classes/Tools/ResetInternalState/reset.php b/Tests/Unit/classes/Tools/ResetInternalState/reset.php new file mode 100644 index 00000000..7f361af9 --- /dev/null +++ b/Tests/Unit/classes/Tools/ResetInternalState/reset.php @@ -0,0 +1,209 @@ +wpdb = Mockery::mock( 'wpdb' ); + $this->wpdb->options = 'wp_options'; + $this->wpdb->sitemeta = 'wp_sitemeta'; + + $GLOBALS['wpdb'] = $this->wpdb; + } + + /** + * @inheritDoc + */ + public function tearDown(): void { + unset( $GLOBALS['wpdb'] ); + + parent::tearDown(); + } + + /** + * reset() calls delete_transient() with every bulk transient name. + */ + public function testDeletesBulkTransients(): void { + $this->wpdb->shouldReceive( 'prepare' )->andReturn( 'PREPARED_SQL' ); + $this->wpdb->shouldReceive( 'query' )->andReturn( 1 ); + + Functions\when( 'is_multisite' )->justReturn( false ); + Functions\when( 'delete_transient' )->justReturn( true ); + + $deleted = []; + Functions\when( 'delete_transient' )->alias( + function ( string $transient ) use ( &$deleted ) { + $deleted[] = $transient; + } + ); + + ( new ResetInternalState() )->reset(); + + $expected_bulk = [ + 'imagify_custom-folders_optimize_running', + 'imagify_wp_optimize_running', + 'imagify_bulk_optimization_complete', + 'imagify_missing_next_gen_total', + 'imagify_bulk_optimization_result', + 'imagify_bulk_optimization_infos', + 'imagify_bulk_optimization_level', + ]; + + foreach ( $expected_bulk as $transient ) { + $this->assertContains( $transient, $deleted, "delete_transient() was not called with '{$transient}'" ); + } + } + + /** + * reset() does NOT call delete_transient() for user/account cache transients. + */ + public function testDoesNotDeleteUserCacheTransients(): void { + $this->wpdb->shouldReceive( 'prepare' )->andReturn( 'PREPARED_SQL' ); + $this->wpdb->shouldReceive( 'query' )->andReturn( 1 ); + + Functions\when( 'is_multisite' )->justReturn( false ); + + $deleted = []; + Functions\when( 'delete_transient' )->alias( + function ( string $transient ) use ( &$deleted ) { + $deleted[] = $transient; + } + ); + + ( new ResetInternalState() )->reset(); + + $user_cache_transients = [ + 'imagify_user', + 'imagify_user_cache', + 'imagify_user_images_count', + 'imagify_large_library', + 'imagify_attachments_number_modal', + 'imagify_stat_without_next_gen', + 'imagify_max_image_size', + 'imagify_check_licence_1', + 'imagify_check_api_version', + 'imagify_settings', + 'imagify_data', + ]; + + foreach ( $user_cache_transients as $transient ) { + $this->assertNotContains( $transient, $deleted, "delete_transient() must NOT be called with '{$transient}'" ); + } + } + + /** + * reset() issues a $wpdb->query() for each locked transient pattern against wp_options. + */ + public function testRunsLikePatternQueryAgainstOptions(): void { + Functions\when( 'delete_transient' )->justReturn( true ); + Functions\when( 'is_multisite' )->justReturn( false ); + + $patterns_queried = []; + + $this->wpdb->shouldReceive( 'prepare' ) + ->andReturnUsing( + function ( string $sql, string $pattern ) use ( &$patterns_queried ) { + $patterns_queried[] = $pattern; + return 'PREPARED_SQL'; + } + ); + + $this->wpdb->shouldReceive( 'query' )->times( 4 )->andReturn( 0 ); + + ( new ResetInternalState() )->reset(); + + $expected_patterns = [ + '\_transient\_%imagify-auto-optimize-%', + '\_transient\_%imagify\_rpc\_%', + '\_transient\_imagify\_%\_process\_locked', + '\_site\_transient\_imagify\_%\_process\_lock%', + ]; + + foreach ( $expected_patterns as $pattern ) { + $this->assertContains( $pattern, $patterns_queried, "No query was prepared for pattern '{$pattern}'" ); + } + } + + /** + * reset() runs a second query against sitemeta when is_multisite() is true. + */ + public function testRunsSitemetaQueryOnMultisite(): void { + Functions\when( 'delete_transient' )->justReturn( true ); + Functions\when( 'is_multisite' )->justReturn( true ); + + $this->wpdb->shouldReceive( 'esc_like' ) + ->with( '_site_transient_imagify_' ) + ->andReturn( '_site_transient_imagify_' ); + + $this->wpdb->shouldReceive( 'prepare' )->andReturn( 'PREPARED_SQL' ); + + $query_calls = 0; + $this->wpdb->shouldReceive( 'query' ) + ->andReturnUsing( + function () use ( &$query_calls ) { + $query_calls++; + return 0; + } + ); + + ( new ResetInternalState() )->reset(); + + // 4 options-pattern queries + 1 sitemeta query = 5 total. + $this->assertGreaterThanOrEqual( 5, $query_calls, 'Expected at least 5 wpdb::query() calls on multisite (4 options + 1 sitemeta)' ); + } + + /** + * reset() skips as_unschedule_all_actions() when ActionScheduler is not loaded. + * + * as_unschedule_all_actions() does not exist in the test environment, so + * function_exists() naturally returns false — no stubbing needed. + * The 4 wpdb::query() calls for the options LIKE patterns are the proof that + * reset() ran to completion without errors. + */ + public function testSkipsSchedulerWhenFunctionNotExists(): void { + $this->wpdb->shouldReceive( 'prepare' )->andReturn( 'PREPARED_SQL' ); + + $query_calls = 0; + $this->wpdb->shouldReceive( 'query' ) + ->andReturnUsing( + function () use ( &$query_calls ) { + $query_calls++; + return 0; + } + ); + + Functions\when( 'delete_transient' )->justReturn( true ); + Functions\when( 'is_multisite' )->justReturn( false ); + + ( new ResetInternalState() )->reset(); + + // 4 options-pattern queries prove reset() ran to completion. + $this->assertSame( 4, $query_calls ); + } +} diff --git a/Tests/Unit/classes/Tools/Subscriber/getSubscribedEvents.php b/Tests/Unit/classes/Tools/Subscriber/getSubscribedEvents.php new file mode 100644 index 00000000..2e1932d5 --- /dev/null +++ b/Tests/Unit/classes/Tools/Subscriber/getSubscribedEvents.php @@ -0,0 +1,48 @@ +assertArrayHasKey( 'wp_ajax_imagify_reset_internal_state', $events ); + $this->assertSame( 'handle_reset', $events['wp_ajax_imagify_reset_internal_state'] ); + } + + /** + * get_subscribed_events() does NOT include the imagify_settings_tools hook. + * + * The settings section is rendered directly via print_template() — no hook needed. + */ + public function testDoesNotContainSettingsToolsHook(): void { + $events = Subscriber::get_subscribed_events(); + + $this->assertArrayNotHasKey( 'imagify_settings_tools', $events ); + } + + /** + * get_subscribed_events() returns exactly one event entry. + */ + public function testReturnsExactlyOneEvent(): void { + $events = Subscriber::get_subscribed_events(); + + $this->assertCount( 1, $events ); + } +} diff --git a/classes/Tools/InternalStateList.php b/classes/Tools/InternalStateList.php new file mode 100644 index 00000000..29475f67 --- /dev/null +++ b/classes/Tools/InternalStateList.php @@ -0,0 +1,79 @@ + + */ + public static function get_bulk_transients(): array { + return [ + 'imagify_custom-folders_optimize_running', + 'imagify_wp_optimize_running', + 'imagify_bulk_optimization_complete', + 'imagify_missing_next_gen_total', + 'imagify_bulk_optimization_result', + 'imagify_bulk_optimization_infos', + 'imagify_bulk_optimization_level', // Stale artifact from old WP_Background_Process, retained for hygiene on upgraded sites. + ]; + } + + /** + * Returns LIKE patterns for process-lock and legacy RPC transients. + * + * Used in a raw SQL DELETE against $wpdb->options (and $wpdb->sitemeta on + * multisite). Patterns follow the format expected by $wpdb->prepare with %s. + * + * @return array + */ + public static function get_locked_transient_patterns(): array { + return [ + '\_transient\_%imagify-auto-optimize-%', // Legacy/deprecated, retained for hygiene on older installs. + '\_transient\_%imagify\_rpc\_%', // Legacy/deprecated. + '\_transient\_imagify\_%\_process\_locked', + '\_site\_transient\_imagify\_%\_process\_lock%', + ]; + } + + /** + * Returns the ActionScheduler hook names to unschedule. + * + * @return array + */ + public static function get_scheduler_hooks(): array { + return [ + 'imagify_optimize_media', + 'imagify_convert_next_gen', + ]; + } +} diff --git a/classes/Tools/ResetInternalState.php b/classes/Tools/ResetInternalState.php new file mode 100644 index 00000000..bd220f31 --- /dev/null +++ b/classes/Tools/ResetInternalState.php @@ -0,0 +1,62 @@ +prepare() + $wpdb->esc_like() — no raw interpolation. + * + * @return void + */ + public function reset(): void { + global $wpdb; + + // 1. Delete named bulk transients. + foreach ( InternalStateList::get_bulk_transients() as $transient ) { + delete_transient( $transient ); + } + + // 2. Delete process-lock and legacy RPC transients via LIKE patterns in wp_options. + foreach ( InternalStateList::get_locked_transient_patterns() as $pattern ) { + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( + "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.LikeWildcardsInQuery + $pattern + ) + ); + } + + // 3. On multisite, also clean up sitemeta process locks. + if ( is_multisite() ) { + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( + "DELETE FROM {$wpdb->sitemeta} WHERE meta_key LIKE %s", // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.LikeWildcardsInQuery + $wpdb->esc_like( '_site_transient_imagify_' ) . '%_process_lock%' + ) + ); + } + + // 4. Unschedule ActionScheduler jobs. + foreach ( InternalStateList::get_scheduler_hooks() as $hook ) { + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( $hook ); + } + } + } +} diff --git a/classes/Tools/ServiceProvider.php b/classes/Tools/ServiceProvider.php new file mode 100644 index 00000000..762176bc --- /dev/null +++ b/classes/Tools/ServiceProvider.php @@ -0,0 +1,62 @@ +provides, true ); + } + + /** + * Registers the provided classes. + * + * @return void + */ + public function register(): void { + $this->getContainer()->add( ResetInternalState::class ); + $this->getContainer()->addShared( Subscriber::class ) + ->addArgument( ResetInternalState::class ); + } + + /** + * Returns the subscribers array. + * + * @return array + */ + public function get_subscribers(): array { + return $this->subscribers; + } +} diff --git a/classes/Tools/Subscriber.php b/classes/Tools/Subscriber.php new file mode 100644 index 00000000..61638bf3 --- /dev/null +++ b/classes/Tools/Subscriber.php @@ -0,0 +1,63 @@ +reset = $reset; + } + + /** + * Returns the events this subscriber listens to. + * + * Note: the settings-section template is rendered directly via print_template() + * in page-settings.php — no imagify_settings_tools hook is registered here. + * + * @return array + */ + public static function get_subscribed_events(): array { + return [ + // @action + 'wp_ajax_imagify_reset_internal_state' => 'handle_reset', + ]; + } + + /** + * Handles the AJAX request to reset Imagify internal state. + * + * Verifies the nonce and capability before delegating to ResetInternalState. + * + * @return void + */ + public function handle_reset(): void { + imagify_check_nonce( 'imagify_reset_internal_state' ); + + if ( ! imagify_get_context( 'wp' )->current_user_can( 'manage' ) ) { + imagify_die(); + return; + } + + $this->reset->reset(); + + wp_send_json_success( [ 'message' => __( 'Imagify internal state has been reset successfully.', 'imagify' ) ] ); + } +} diff --git a/config/providers.php b/config/providers.php index 1f29c89f..c762b578 100644 --- a/config/providers.php +++ b/config/providers.php @@ -9,4 +9,5 @@ 'Imagify\Webp\ServiceProvider', 'Imagify\ThirdParty\ServiceProvider', 'Imagify\Media\ServiceProvider', + 'Imagify\Tools\ServiceProvider', ]; diff --git a/uninstall.php b/uninstall.php index f78bfc3b..03864a04 100755 --- a/uninstall.php +++ b/uninstall.php @@ -1,6 +1,8 @@ query( "DELETE from $wpdb->options WHERE option_name LIKE \"$transients\"" ); // WPCS: unprepared SQL ok. +foreach ( \Imagify\Tools\InternalStateList::get_locked_transient_patterns() as $pattern ) { + $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $pattern ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.LikeWildcardsInQuery +} // Clear scheduled hooks. wp_clear_scheduled_hook( 'imagify_rating_event' ); From 6927a68e32f4f20461c8effa81e4ffd430446b10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Robin?= Date: Fri, 12 Jun 2026 17:10:13 +0200 Subject: [PATCH 2/6] feat(settings): add Troubleshooting section UI for reset internal state tool Adds the settings page UI for the one-click internal state reset: views/part-settings-tools.php template with data-nonce button, JS click handler in options.js posting _wpnonce to the AJAX endpoint, i18n strings in the options localization, and a Playwright E2E spec. Co-Authored-By: Claude Sonnet 4.6 --- Tests/e2e/specs/reset-internal-state.spec.ts | 85 ++++++++++++++++++++ assets/js/options.js | 45 +++++++++++ assets/js/options.min.js | 2 +- inc/functions/i18n.php | 10 ++- views/page-settings.php | 1 + views/part-settings-tools.php | 16 ++++ 6 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 Tests/e2e/specs/reset-internal-state.spec.ts create mode 100644 views/part-settings-tools.php diff --git a/Tests/e2e/specs/reset-internal-state.spec.ts b/Tests/e2e/specs/reset-internal-state.spec.ts new file mode 100644 index 00000000..17f568a0 --- /dev/null +++ b/Tests/e2e/specs/reset-internal-state.spec.ts @@ -0,0 +1,85 @@ +import { test, expect } from '@playwright/test'; +import { loginAsAdmin } from '../fixtures/auth'; +import { SettingsPage } from '../pages/settings'; +import { screenshotElement } from '../fixtures/screenshot'; + +/** + * Imagify Reset Internal State — Troubleshooting section tests. + * + * The Troubleshooting section (and its Reset Internal State button) is only + * rendered when a valid API key is present. Tests that require the button + * assert its presence with a hard failure (no test.skip) so that missing + * seed data is immediately visible rather than silently skipped. + * + * Seed requirement: IMAGIFY_TESTS_API_KEY must be set and valid for tests + * that interact with the button. + */ +test.describe( 'Reset Internal State — Troubleshooting section', () => { + test.beforeEach( async ( { page } ) => { + await loginAsAdmin( page ); + } ); + + test( 'Troubleshooting section is visible on settings page (requires API key)', async ( { page } ) => { + if ( ! process.env.IMAGIFY_TESTS_API_KEY ) { + // Hard-fail with an informative message instead of silently skipping. + expect( + process.env.IMAGIFY_TESTS_API_KEY, + 'IMAGIFY_TESTS_API_KEY env var must be set — the Troubleshooting section is only rendered when a valid API key is configured. Set this variable to a valid key and re-run.' + ).toBeTruthy(); + return; + } + + const settings = new SettingsPage( page ); + await settings.goto(); + + const section = page.locator( '#imagify-reset-internal-state' ).locator( '..' ); + await expect( section ).toBeVisible(); + + await screenshotElement( page, 'reset-internal-state-section', section ); + } ); + + test( 'Reset Internal State button is present and has a nonce attribute (requires API key)', async ( { page } ) => { + if ( ! process.env.IMAGIFY_TESTS_API_KEY ) { + expect( + process.env.IMAGIFY_TESTS_API_KEY, + 'IMAGIFY_TESTS_API_KEY env var must be set — the Reset Internal State button is only rendered when a valid API key is configured.' + ).toBeTruthy(); + return; + } + + const settings = new SettingsPage( page ); + await settings.goto(); + + const button = page.locator( '#imagify-reset-internal-state' ); + await expect( button ).toBeVisible(); + + // The button must carry a data-nonce attribute (non-empty). + const nonce = await button.getAttribute( 'data-nonce' ); + expect( nonce, 'Reset button must have a non-empty data-nonce attribute' ).toBeTruthy(); + + await screenshotElement( page, 'reset-internal-state-button', button ); + } ); + + test( 'Reset Internal State button is clickable (requires API key)', async ( { page } ) => { + if ( ! process.env.IMAGIFY_TESTS_API_KEY ) { + expect( + process.env.IMAGIFY_TESTS_API_KEY, + 'IMAGIFY_TESTS_API_KEY env var must be set — the Reset Internal State button is only rendered when a valid API key is configured.' + ).toBeTruthy(); + return; + } + + const settings = new SettingsPage( page ); + await settings.goto(); + + const button = page.locator( '#imagify-reset-internal-state' ); + await expect( button ).toBeVisible(); + await expect( button ).toBeEnabled(); + + // The button should not be disabled before any interaction. + const isDisabled = await button.isDisabled(); + expect( isDisabled ).toBe( false ); + + await screenshotElement( page, 'reset-internal-state-clickable', button ); + } ); +} ); diff --git a/assets/js/options.js b/assets/js/options.js index 2143f1d1..66b4624e 100755 --- a/assets/js/options.js +++ b/assets/js/options.js @@ -146,6 +146,51 @@ window.imagify = window.imagify || {}; } } ).filter( ':checked' ).trigger( 'init.imagify' ); + /** + * Reset Internal State button. + */ + $( '#imagify-reset-internal-state' ).on( 'click.imagify', function() { + var $button = $( this ), + nonce = $button.data( 'nonce' ), + $feedback = $( '#imagify-reset-internal-state-feedback' ); + + swal( { + title: imagifyOptions.resetInternalState.confirm, + type: 'warning', + customClass: 'imagify-sweet-alert', + padding: 0, + showCancelButton: true, + cancelButtonText: imagifySwal.labels.cancelButtonText, + reverseButtons: true + } ).then( + function() { + $button.prop( 'disabled', true ); + $feedback.text( '' ).removeClass( 'imagify-success imagify-error' ); + + $.post( ajaxurl, { + 'action': imagifyOptions.resetInternalState.action, + '_wpnonce': nonce + } ) + .done( function( response ) { + if ( response && response.success ) { + $feedback.text( imagifyOptions.resetInternalState.success ).addClass( 'imagify-success' ); + } else { + $feedback.text( imagifyOptions.resetInternalState.error ).addClass( 'imagify-error' ); + } + } ) + .fail( function() { + $feedback.text( imagifyOptions.resetInternalState.error ).addClass( 'imagify-error' ); + } ) + .always( function() { + $button.prop( 'disabled', false ); + } ); + }, + function() { + // User cancelled — do nothing. + } + ); + } ); + } )(jQuery, document, window); diff --git a/assets/js/options.min.js b/assets/js/options.min.js index 749ad62f..0024be59 100755 --- a/assets/js/options.min.js +++ b/assets/js/options.min.js @@ -1 +1 @@ -window.imagify=window.imagify||{},((t,a)=>{var s=!1,o=!1;t("#imagify-settings #api_key").on("blur.imagify",function(){var i=t(this),e=i.val();return""!==String(e).trim()&&(t("#check_api_key").val()===e?(t("#imagify-check-api-container").html(' '+imagifyOptions.labels.ValidApiKeyText),!1):(!0===s?o.abort():(t("#imagify-check-api-container").remove(),i.after(''+imagifyOptions.labels.waitApiKeyCheckText+"")),s=!0,void(o=t.get(ajaxurl+a.imagify.concat+"action=imagify_check_api_key_validity&api_key="+i.val()+"&imagifycheckapikeynonce="+t("#imagifycheckapikeynonce").val()).done(function(i){i.success?(t("#imagify-check-api-container").remove(),swal({title:imagifyOptions.labels.ApiKeyCheckSuccessTitle,html:imagifyOptions.labels.ApiKeyCheckSuccessText,type:"success",padding:0,customClass:"imagify-sweet-alert"}).then(function(){location.reload()})):t("#imagify-check-api-container").html(' '+i.data),s=!1}))))}),t(".imagify-options-line").css("cursor","pointer").on("click.imagify",function(i){"INPUT"!==i.target.nodeName&&t('input[aria-describedby="'+t(this).attr("id")+'"]').trigger("click.imagify")}),t(".imagify-settings th span").on("click.imagify",function(){var i=t(this).parent().next("td").find(":checkbox");1===i.length&&i.trigger("click.imagify")}),t(".imagify-options-line").find("input").on("change.imagify focus.imagify",function(){var i;"checkbox"===this.type&&!this.checked||!(i=t(this).closest(".imagify-options-line").prev("label").prev(":checkbox")).length||i[0].checked||i.prop("checked",!0)}),t('[name="imagify_settings[backup]"]').on("change.imagify",function(){var i=t(this),e=i.siblings("#backup-dir-is-writable"),a={action:"imagify_check_backup_dir_is_writable",_wpnonce:e.data("nonce")};i.is(":checked")?t.getJSON(ajaxurl,a).done(function(i){t.isPlainObject(i)&&i.success&&(i.data.is_writable?e.addClass("hidden"):e.removeClass("hidden"))}):swal({title:imagifyOptions.labels.noBackupTitle,html:imagifyOptions.labels.noBackupText,type:"warning",customClass:"imagify-sweet-alert",padding:0,showCancelButton:!0,cancelButtonText:imagifySwal.labels.cancelButtonText,reverseButtons:!0}).then(function(){e.addClass("hidden")},function(){i.prop("checked",!0)})}),t('[name="imagify_settings[display_nextgen_method]"]').on("change.imagify init.imagify",function(i){"picture"===i.target.value?t(i.target).closest(".imagify-radio-group").next(".imagify-options-line").removeClass("imagify-faded"):t(i.target).closest(".imagify-radio-group").next(".imagify-options-line").addClass("imagify-faded")}).filter(":checked").trigger("init.imagify")})(jQuery,(document,window)),((i,s)=>{i.imagifyUser&&s.getJSON(ajaxurl,i.imagifyUser).done(function(t){s.isPlainObject(t)&&t.success&&(t.data.id=null,t.data.plan_id=null,t.data.is=[],s.each(t.data,function(i,e){var a=".imagify-user-"+i.replace(/_/g,"-");0===i.indexOf("is_")?e&&t.data.is.push(a):"is"!==i&&s(a).text(e)}),t.data.is.push("best-plan"),s(t.data.is.join(",")).removeClass("hidden"))})})(window,(document,jQuery)),((e,i,g)=>{function a(a){var t,s,o,n,l=!1,r=null;a&&(o=g("#imagify-custom-folders-selected"),(n=o.find(".imagify-custom-folder-line")).find('[value="'+a+'"]').length||(a=a.split("#///#"),t=a[1].replace(/\/+$/,"").toLowerCase(),s=e.imagify.template("imagify-custom-folder"),n.each(function(){var i=g(this),e=i.data("path").replace(/\/+$/,"").toLowerCase();return""!==e&&0===t.indexOf(e)?!(l=!0):t'+imagifyOptions.labels.filesTreeSubTitle+'

'+imagifyOptions.labels.cleaningInfo+'

    '+i.data+"
",type:"",customClass:"imagify-sweet-alert imagify-swal-has-subtitle imagify-folders-selection",showCancelButton:!0,padding:0,confirmButtonText:imagifyOptions.labels.confirmFilesTreeBtn,cancelButtonText:imagifySwal.labels.cancelButtonText,reverseButtons:!0}).then(function(){var i=g("#imagify-folders-tree input").serializeArray();i.length&&g.each(i,function(i,e){a(e.value)})}).catch(swal.noop):swal({title:imagifyOptions.labels.error,html:i.data||"",type:"error",padding:0,customClass:"imagify-sweet-alert"})}).fail(function(){swal({title:imagifyOptions.labels.error,type:"error",customClass:"imagify-sweet-alert",padding:0})}).always(function(){i.prop("disabled",!1).next("img").attr("aria-hidden","true")}))}),g(i).on("click.imagify","#imagify-folders-tree [data-folder]",function(){var e=g(this),i=e.nextAll(".imagify-folders-sub-tree"),a=[];e.prop("disabled")||e.siblings(":checkbox").is(":checked")||(e.prop("disabled",!0).addClass("imagify-loading"),i.length?(e.hasClass("imagify-is-open")?(i.addClass("hidden"),e.removeClass(" imagify-is-open")):(i.removeClass("hidden"),e.addClass("imagify-is-open")),e.prop("disabled",!1).removeClass("imagify-loading")):(g("#imagify-custom-folders-selected").find("input").each(function(){a.push(this.value)}),g.post(imagifyOptions.getFilesTree,{folder:e.data("folder"),selected:a},null,"json").done(function(i){i.success?e.addClass("imagify-is-open").parent().append('
    '+i.data+"
"):swal({title:imagifyOptions.labels.error,html:i.data||"",type:"error",padding:0,customClass:"imagify-sweet-alert"})}).fail(function(){swal({title:imagifyOptions.labels.error,type:"error",padding:0,customClass:"imagify-sweet-alert"})}).always(function(){e.prop("disabled",!1).removeClass("imagify-loading")})))}),g("#imagify-custom-folders").on("click.imagify",".imagify-custom-folders-remove",function(){var i=g(this).closest(".imagify-custom-folder-line").addClass("imagify-will-remove");e.setTimeout(function(){i.remove(),g("#imagify-custom-folders-selected").siblings(".imagify-success.hidden").removeClass("hidden")},750)}),g("#imagify-add-themes-to-custom-folder").on("click.imagify",function(){var i=g(this);a(i.data("theme")),a(i.data("theme-parent")),i.replaceWith("

"+imagifyOptions.labels.themesAdded+"

")}))})(window,document,jQuery),((t,a,s)=>{imagifyOptions.bulk&&(t.imagify.optionsBulk={error:!1,working:!1,processIsStopped:!1,$button:null,$progressWrap:null,$progressBar:null,$progressText:null,init:function(){var i,e;this.$missingWebpElement=s(".generate-missing-webp"),this.$missingWebpMessage=s(".generate-missing-webp p"),this.$button=s("#imagify-generate-webp-versions"),this.$progressWrap=this.$button.siblings(".imagify-progress"),this.$progressBar=this.$progressWrap.find(".bar"),this.$progressText=this.$progressBar.find(".percent"),s("#imagify_convert_to_webp").on("change.imagify init.imagify",{imagifyOptionsBulk:this},this.toggleButton).trigger("init.imagify"),this.$button.on("click.imagify",{imagifyOptionsBulk:this},this.maybeLaunchMissingWebpProcess),s(a).on("imagifybeat-send",{imagifyOptionsBulk:this},this.addQueueImagifybeat).on("imagifybeat-tick",{imagifyOptionsBulk:this},this.processQueueImagifybeat).on("imagifybeat-send",this.addRequirementsImagifybeat).on("imagifybeat-tick",{imagifyOptionsBulk:this},this.processRequirementsImagifybeat),!1!==imagifyOptions.bulk.progress_next_gen.total&&!1!==imagifyOptions.bulk.progress_next_gen.remaining&&(t.imagify.optionsBulk.error=!1,t.imagify.optionsBulk.working=!0,t.imagify.optionsBulk.processIsStopped=!1,this.$button.prop("disabled",!0).find(".dashicons").addClass("rotate"),t.imagify.beat.interval(15),t.imagify.beat.disableSuspend(),this.$missingWebpMessage.hide().attr("aria-hidden","true"),i=imagifyOptions.bulk.progress_next_gen.total-imagifyOptions.bulk.progress_next_gen.remaining,e=Math.floor(i/imagifyOptions.bulk.progress_next_gen.total*100),this.$progressBar.css("width",e+"%"),this.$progressText.text(i+"/"+imagifyOptions.bulk.progress_next_gen.total),this.$progressWrap.slideDown().attr("aria-hidden","false").removeClass("hidden"))},toggleButton:function(i){this.checked?i.data.imagifyOptionsBulk.$button.prop("disabled",!1):i.data.imagifyOptionsBulk.$button.prop("disabled",!0)},maybeLaunchMissingWebpProcess:function(i){!i.data.imagifyOptionsBulk||i.data.imagifyOptionsBulk.working||i.data.imagifyOptionsBulk.hasBlockingError(!0)||(i.data.imagifyOptionsBulk.error=!1,i.data.imagifyOptionsBulk.working=!0,i.data.imagifyOptionsBulk.processIsStopped=!1,i.data.imagifyOptionsBulk.$button.prop("disabled",!0).find(".dashicons").addClass("rotate"),t.imagify.beat.interval(15),t.imagify.beat.disableSuspend(),i.data.imagifyOptionsBulk.launchProcess())},addQueueImagifybeat:function(i,e){e[imagifyOptions.bulk.imagifybeatIDs.progress]=imagifyOptions.bulk.contexts},processQueueImagifybeat:function(i,e){var a,t;i.data.imagifyOptionsBulk&&void 0===e[imagifyOptions.bulk.imagifybeatIDs.progress]||(i.data.imagifyOptionsBulk.processIsStopped||0===(e=e[imagifyOptions.bulk.imagifybeatIDs.progress]).remaining?i.data.imagifyOptionsBulk.processFinished():(a=e.total-e.remaining,t=Math.floor(a/e.total*100),i.data.imagifyOptionsBulk.$progressBar.css("width",t+"%"),i.data.imagifyOptionsBulk.$progressText.text(a+"/"+e.total)))},addRequirementsImagifybeat:function(i,e){e[imagifyOptions.bulk.imagifybeatIDs.requirements]=1},processRequirementsImagifybeat:function(i,e){i.data.imagifyOptionsBulk&&void 0===e[imagifyOptions.bulk.imagifybeatIDs.requirements]||(e=e[imagifyOptions.bulk.imagifybeatIDs.requirements],imagifyOptions.bulk.curlMissing=e.curl_missing,imagifyOptions.bulk.editorMissing=e.editor_missing,imagifyOptions.bulk.extHttpBlocked=e.external_http_blocked,imagifyOptions.bulk.apiDown=e.api_down,imagifyOptions.bulk.keyIsValid=e.key_is_valid,imagifyOptions.bulk.isOverQuota=e.is_over_quota)},launchProcess:function(){var a;this.processIsStopped||s.get((a=this).getAjaxUrl("MissingNextGen",imagifyOptions.bulk.contexts)).done(function(i){var e;a.processIsStopped||(e=i.data&&i.data.message?i.data.message:imagifyOptions.bulk.ajaxErrorText,i.success?0===i.data.total?a.stopProcess("no-images"):(a.$missingWebpMessage.hide().attr("aria-hidden","true"),a.$progressText.text("0"+(i.data.total?"/"+i.data.total:"")),a.$progressWrap.slideDown().attr("aria-hidden","false").removeClass("hidden")):a.error||a.stopProcess(e))}).fail(function(){a.error||a.stopProcess("get-unoptimized-images")})},processFinished:function(){var i={};!1!==this.error&&(i="invalid-api-key"===this.error?{title:imagifyOptions.bulk.labels.invalidAPIKeyTitle,type:"info"}:"over-quota"===this.error?{title:imagifyOptions.bulk.labels.overQuotaTitle,html:s("#tmpl-imagify-overquota-alert").html(),type:"info",customClass:"imagify-swal-has-subtitle imagify-swal-error-header",showConfirmButton:!1}:"get-unoptimized-images"===this.error?{title:imagifyOptions.bulk.labels.getUnoptimizedImagesErrorTitle,html:imagifyOptions.bulk.labels.getUnoptimizedImagesErrorText,type:"info"}:"no-images"===this.error?{title:imagifyOptions.bulk.labels.nothingToDoTitle,html:imagifyOptions.bulk.labels.nothingToDoText,type:"info"}:"no-backup"===this.error?{title:imagifyOptions.bulk.labels.nothingToDoTitle,html:imagifyOptions.bulk.labels.nothingToDoNoBackupText,type:"info"}:{title:imagifyOptions.bulk.labels.error,html:this.error,type:"info"},this.displayError(i),this.error=!1),this.working=!1,this.processIsStopped=!1,t.imagify.beat.resetInterval(),t.imagify.beat.enableSuspend(),this.$progressWrap.slideUp().attr("aria-hidden","true").addClass("hidden"),this.$progressText.text("0"),this.$missingWebpElement.hide().attr("aria-hidden","true"),this.$button.find(".dashicons").removeClass("rotate")},hasBlockingError:function(i){return i=void 0!==i&&i,imagifyOptions.bulk.curlMissing?(i&&this.displayError({html:imagifyOptions.bulk.labels.curlMissing}),!0):imagifyOptions.bulk.editorMissing?(i&&this.displayError({html:imagifyOptions.bulk.labels.editorMissing}),!0):imagifyOptions.bulk.extHttpBlocked?(i&&this.displayError({html:imagifyOptions.bulk.labels.extHttpBlocked}),!0):imagifyOptions.bulk.apiDown?(i&&this.displayError({html:imagifyOptions.bulk.labels.apiDown}),!0):imagifyOptions.bulk.keyIsValid?!!imagifyOptions.bulk.isOverQuota&&(i&&this.displayError({title:imagifyOptions.bulk.labels.overQuotaTitle,html:s("#tmpl-imagify-overquota-alert").html(),type:"info",customClass:"imagify-swal-has-subtitle imagify-swal-error-header",showConfirmButton:!1}),!0):(i&&this.displayError({title:imagifyOptions.bulk.labels.invalidAPIKeyTitle,type:"info"}),!0)},displayError:function(i,e,a){var t={title:"",html:"",type:"error",customClass:"",width:620,padding:0,showCloseButton:!0,showConfirmButton:!0};(a=s.isPlainObject(i)?s.extend({},t,i):s.extend({},t,{title:i||"",html:e||""},a=a||{})).title=a.title||imagifyOptions.bulk.labels.error,a.customClass+=" imagify-sweet-alert",swal(a).catch(swal.noop)},getAjaxUrl:function(i,e){var a=ajaxurl+t.imagify.concat+"_wpnonce="+imagifyOptions.bulk.ajaxNonce;return(a+="&action="+imagifyOptions.bulk.ajaxActions[i])+("&context="+e.join("_"))},stopProcess:function(i){this.processIsStopped=!0,this.error=i,this.processFinished()}},t.imagify.optionsBulk.init())})(window,document,jQuery),(o=>{var t=o.propHooks.checked;o.propHooks.checked={set:function(i,e,a){e=void 0===t?i[a]=e:t(i,e,a);return o(i).trigger("change.imagify"),e}},o(".imagify-select-all").on("click.imagify",function(){var i=o(this),e=i.data("action"),a=i.closest(".imagify-select-all-buttons"),t=a.prev(".imagify-check-group"),s="imagify-is-inactive";if(i.hasClass(s))return!1;a.find(".imagify-select-all").removeClass(s).attr("aria-disabled","false"),i.addClass(s).attr("aria-disabled","true"),t.find(".imagify-row-check").prop("checked",function(){return!o(this).is(":hidden,:disabled")&&"select"===e})}),o(".imagify-check-group .imagify-row-check").on("change.imagify",function(){var i=o(this).closest(".imagify-check-group"),e=i.find(".imagify-row-check"),a=e.filter(":visible:enabled").length,e=e.filter(":visible:enabled:checked").length,i=i.next(".imagify-select-all-buttons"),t="imagify-is-inactive";0===e&&i.find('[data-action="unselect"]').addClass(t).attr("aria-disabled","true"),e===a&&i.find('[data-action="select"]').addClass(t).attr("aria-disabled","true"),e!==a&&0 '+imagifyOptions.labels.ValidApiKeyText),!1):(!0===s?n.abort():(a("#imagify-check-api-container").remove(),i.after(''+imagifyOptions.labels.waitApiKeyCheckText+"")),s=!0,void(n=a.get(ajaxurl+t.imagify.concat+"action=imagify_check_api_key_validity&api_key="+i.val()+"&imagifycheckapikeynonce="+a("#imagifycheckapikeynonce").val()).done(function(i){i.success?(a("#imagify-check-api-container").remove(),swal({title:imagifyOptions.labels.ApiKeyCheckSuccessTitle,html:imagifyOptions.labels.ApiKeyCheckSuccessText,type:"success",padding:0,customClass:"imagify-sweet-alert"}).then(function(){location.reload()})):a("#imagify-check-api-container").html(' '+i.data),s=!1}))))}),a(".imagify-options-line").css("cursor","pointer").on("click.imagify",function(i){"INPUT"!==i.target.nodeName&&a('input[aria-describedby="'+a(this).attr("id")+'"]').trigger("click.imagify")}),a(".imagify-settings th span").on("click.imagify",function(){var i=a(this).parent().next("td").find(":checkbox");1===i.length&&i.trigger("click.imagify")}),a(".imagify-options-line").find("input").on("change.imagify focus.imagify",function(){var i;"checkbox"===this.type&&!this.checked||!(i=a(this).closest(".imagify-options-line").prev("label").prev(":checkbox")).length||i[0].checked||i.prop("checked",!0)}),a('[name="imagify_settings[backup]"]').on("change.imagify",function(){var i=a(this),e=i.siblings("#backup-dir-is-writable"),t={action:"imagify_check_backup_dir_is_writable",_wpnonce:e.data("nonce")};i.is(":checked")?a.getJSON(ajaxurl,t).done(function(i){a.isPlainObject(i)&&i.success&&(i.data.is_writable?e.addClass("hidden"):e.removeClass("hidden"))}):swal({title:imagifyOptions.labels.noBackupTitle,html:imagifyOptions.labels.noBackupText,type:"warning",customClass:"imagify-sweet-alert",padding:0,showCancelButton:!0,cancelButtonText:imagifySwal.labels.cancelButtonText,reverseButtons:!0}).then(function(){e.addClass("hidden")},function(){i.prop("checked",!0)})}),a('[name="imagify_settings[display_nextgen_method]"]').on("change.imagify init.imagify",function(i){"picture"===i.target.value?a(i.target).closest(".imagify-radio-group").next(".imagify-options-line").removeClass("imagify-faded"):a(i.target).closest(".imagify-radio-group").next(".imagify-options-line").addClass("imagify-faded")}).filter(":checked").trigger("init.imagify"),a("#imagify-reset-internal-state").on("click.imagify",function(){var i=a(this),e=i.data("nonce"),t=a("#imagify-reset-internal-state-feedback");swal({title:imagifyOptions.resetInternalState.confirm,type:"warning",customClass:"imagify-sweet-alert",padding:0,showCancelButton:!0,cancelButtonText:imagifySwal.labels.cancelButtonText,reverseButtons:!0}).then(function(){i.prop("disabled",!0),t.text("").removeClass("imagify-success imagify-error"),a.post(ajaxurl,{action:imagifyOptions.resetInternalState.action,_wpnonce:e}).done(function(i){i&&i.success?t.text(imagifyOptions.resetInternalState.success).addClass("imagify-success"):t.text(imagifyOptions.resetInternalState.error).addClass("imagify-error")}).fail(function(){t.text(imagifyOptions.resetInternalState.error).addClass("imagify-error")}).always(function(){i.prop("disabled",!1)})},function(){})})}(jQuery,(document,window)),function(i,s){i.imagifyUser&&s.getJSON(ajaxurl,i.imagifyUser).done(function(a){s.isPlainObject(a)&&a.success&&(a.data.id=null,a.data.plan_id=null,a.data.is=[],s.each(a.data,function(i,e){var t=".imagify-user-"+i.replace(/_/g,"-");0===i.indexOf("is_")?e&&a.data.is.push(t):"is"!==i&&s(t).text(e)}),a.data.is.push("best-plan"),s(a.data.is.join(",")).removeClass("hidden"))})}(window,(document,jQuery)),function(e,i,g){function t(t){var a,s,n,o,l=!1,r=null;t&&(n=g("#imagify-custom-folders-selected"),(o=n.find(".imagify-custom-folder-line")).find('[value="'+t+'"]').length||(t=t.split("#///#"),a=t[1].replace(/\/+$/,"").toLowerCase(),s=e.imagify.template("imagify-custom-folder"),o.each(function(){var i=g(this),e=i.data("path").replace(/\/+$/,"").toLowerCase();return""!==e&&0===a.indexOf(e)?!(l=!0):a'+imagifyOptions.labels.filesTreeSubTitle+'

'+imagifyOptions.labels.cleaningInfo+'

    '+i.data+"
",type:"",customClass:"imagify-sweet-alert imagify-swal-has-subtitle imagify-folders-selection",showCancelButton:!0,padding:0,confirmButtonText:imagifyOptions.labels.confirmFilesTreeBtn,cancelButtonText:imagifySwal.labels.cancelButtonText,reverseButtons:!0}).then(function(){var i=g("#imagify-folders-tree input").serializeArray();i.length&&g.each(i,function(i,e){t(e.value)})}).catch(swal.noop):swal({title:imagifyOptions.labels.error,html:i.data||"",type:"error",padding:0,customClass:"imagify-sweet-alert"})}).fail(function(){swal({title:imagifyOptions.labels.error,type:"error",customClass:"imagify-sweet-alert",padding:0})}).always(function(){i.prop("disabled",!1).next("img").attr("aria-hidden","true")}))}),g(i).on("click.imagify","#imagify-folders-tree [data-folder]",function(){var e=g(this),i=e.nextAll(".imagify-folders-sub-tree"),t=[];e.prop("disabled")||e.siblings(":checkbox").is(":checked")||(e.prop("disabled",!0).addClass("imagify-loading"),i.length?(e.hasClass("imagify-is-open")?(i.addClass("hidden"),e.removeClass(" imagify-is-open")):(i.removeClass("hidden"),e.addClass("imagify-is-open")),e.prop("disabled",!1).removeClass("imagify-loading")):(g("#imagify-custom-folders-selected").find("input").each(function(){t.push(this.value)}),g.post(imagifyOptions.getFilesTree,{folder:e.data("folder"),selected:t},null,"json").done(function(i){i.success?e.addClass("imagify-is-open").parent().append('
    '+i.data+"
"):swal({title:imagifyOptions.labels.error,html:i.data||"",type:"error",padding:0,customClass:"imagify-sweet-alert"})}).fail(function(){swal({title:imagifyOptions.labels.error,type:"error",padding:0,customClass:"imagify-sweet-alert"})}).always(function(){e.prop("disabled",!1).removeClass("imagify-loading")})))}),g("#imagify-custom-folders").on("click.imagify",".imagify-custom-folders-remove",function(){var i=g(this).closest(".imagify-custom-folder-line").addClass("imagify-will-remove");e.setTimeout(function(){i.remove(),g("#imagify-custom-folders-selected").siblings(".imagify-success.hidden").removeClass("hidden")},750)}),g("#imagify-add-themes-to-custom-folder").on("click.imagify",function(){var i=g(this);t(i.data("theme")),t(i.data("theme-parent")),i.replaceWith("

"+imagifyOptions.labels.themesAdded+"

")}))}(window,document,jQuery),function(a,t,s){imagifyOptions.bulk&&(a.imagify.optionsBulk={error:!1,working:!1,processIsStopped:!1,$button:null,$progressWrap:null,$progressBar:null,$progressText:null,init:function(){var i,e;this.$missingWebpElement=s(".generate-missing-webp"),this.$missingWebpMessage=s(".generate-missing-webp p"),this.$button=s("#imagify-generate-webp-versions"),this.$progressWrap=this.$button.siblings(".imagify-progress"),this.$progressBar=this.$progressWrap.find(".bar"),this.$progressText=this.$progressBar.find(".percent"),s("#imagify_convert_to_webp").on("change.imagify init.imagify",{imagifyOptionsBulk:this},this.toggleButton).trigger("init.imagify"),this.$button.on("click.imagify",{imagifyOptionsBulk:this},this.maybeLaunchMissingWebpProcess),s(t).on("imagifybeat-send",{imagifyOptionsBulk:this},this.addQueueImagifybeat).on("imagifybeat-tick",{imagifyOptionsBulk:this},this.processQueueImagifybeat).on("imagifybeat-send",this.addRequirementsImagifybeat).on("imagifybeat-tick",{imagifyOptionsBulk:this},this.processRequirementsImagifybeat),!1!==imagifyOptions.bulk.progress_next_gen.total&&!1!==imagifyOptions.bulk.progress_next_gen.remaining&&(a.imagify.optionsBulk.error=!1,a.imagify.optionsBulk.working=!0,a.imagify.optionsBulk.processIsStopped=!1,this.$button.prop("disabled",!0).find(".dashicons").addClass("rotate"),a.imagify.beat.interval(15),a.imagify.beat.disableSuspend(),this.$missingWebpMessage.hide().attr("aria-hidden","true"),i=imagifyOptions.bulk.progress_next_gen.total-imagifyOptions.bulk.progress_next_gen.remaining,e=Math.floor(i/imagifyOptions.bulk.progress_next_gen.total*100),this.$progressBar.css("width",e+"%"),this.$progressText.text(i+"/"+imagifyOptions.bulk.progress_next_gen.total),this.$progressWrap.slideDown().attr("aria-hidden","false").removeClass("hidden"))},toggleButton:function(i){this.checked?i.data.imagifyOptionsBulk.$button.prop("disabled",!1):i.data.imagifyOptionsBulk.$button.prop("disabled",!0)},maybeLaunchMissingWebpProcess:function(i){!i.data.imagifyOptionsBulk||i.data.imagifyOptionsBulk.working||i.data.imagifyOptionsBulk.hasBlockingError(!0)||(i.data.imagifyOptionsBulk.error=!1,i.data.imagifyOptionsBulk.working=!0,i.data.imagifyOptionsBulk.processIsStopped=!1,i.data.imagifyOptionsBulk.$button.prop("disabled",!0).find(".dashicons").addClass("rotate"),a.imagify.beat.interval(15),a.imagify.beat.disableSuspend(),i.data.imagifyOptionsBulk.launchProcess())},addQueueImagifybeat:function(i,e){e[imagifyOptions.bulk.imagifybeatIDs.progress]=imagifyOptions.bulk.contexts},processQueueImagifybeat:function(i,e){var t,a;i.data.imagifyOptionsBulk&&void 0===e[imagifyOptions.bulk.imagifybeatIDs.progress]||(i.data.imagifyOptionsBulk.processIsStopped||0===(e=e[imagifyOptions.bulk.imagifybeatIDs.progress]).remaining?i.data.imagifyOptionsBulk.processFinished():(t=e.total-e.remaining,a=Math.floor(t/e.total*100),i.data.imagifyOptionsBulk.$progressBar.css("width",a+"%"),i.data.imagifyOptionsBulk.$progressText.text(t+"/"+e.total)))},addRequirementsImagifybeat:function(i,e){e[imagifyOptions.bulk.imagifybeatIDs.requirements]=1},processRequirementsImagifybeat:function(i,e){i.data.imagifyOptionsBulk&&void 0===e[imagifyOptions.bulk.imagifybeatIDs.requirements]||(e=e[imagifyOptions.bulk.imagifybeatIDs.requirements],imagifyOptions.bulk.curlMissing=e.curl_missing,imagifyOptions.bulk.editorMissing=e.editor_missing,imagifyOptions.bulk.extHttpBlocked=e.external_http_blocked,imagifyOptions.bulk.apiDown=e.api_down,imagifyOptions.bulk.keyIsValid=e.key_is_valid,imagifyOptions.bulk.isOverQuota=e.is_over_quota)},launchProcess:function(){var t;this.processIsStopped||s.get((t=this).getAjaxUrl("MissingNextGen",imagifyOptions.bulk.contexts)).done(function(i){var e;t.processIsStopped||(e=i.data&&i.data.message?i.data.message:imagifyOptions.bulk.ajaxErrorText,i.success?0===i.data.total?t.stopProcess("no-images"):(t.$missingWebpMessage.hide().attr("aria-hidden","true"),t.$progressText.text("0"+(i.data.total?"/"+i.data.total:"")),t.$progressWrap.slideDown().attr("aria-hidden","false").removeClass("hidden")):t.error||t.stopProcess(e))}).fail(function(){t.error||t.stopProcess("get-unoptimized-images")})},processFinished:function(){var i={};!1!==this.error&&(i="invalid-api-key"===this.error?{title:imagifyOptions.bulk.labels.invalidAPIKeyTitle,type:"info"}:"over-quota"===this.error?{title:imagifyOptions.bulk.labels.overQuotaTitle,html:s("#tmpl-imagify-overquota-alert").html(),type:"info",customClass:"imagify-swal-has-subtitle imagify-swal-error-header",showConfirmButton:!1}:"get-unoptimized-images"===this.error?{title:imagifyOptions.bulk.labels.getUnoptimizedImagesErrorTitle,html:imagifyOptions.bulk.labels.getUnoptimizedImagesErrorText,type:"info"}:"no-images"===this.error?{title:imagifyOptions.bulk.labels.nothingToDoTitle,html:imagifyOptions.bulk.labels.nothingToDoText,type:"info"}:"no-backup"===this.error?{title:imagifyOptions.bulk.labels.nothingToDoTitle,html:imagifyOptions.bulk.labels.nothingToDoNoBackupText,type:"info"}:{title:imagifyOptions.bulk.labels.error,html:this.error,type:"info"},this.displayError(i),this.error=!1),this.working=!1,this.processIsStopped=!1,a.imagify.beat.resetInterval(),a.imagify.beat.enableSuspend(),this.$progressWrap.slideUp().attr("aria-hidden","true").addClass("hidden"),this.$progressText.text("0"),this.$missingWebpElement.hide().attr("aria-hidden","true"),this.$button.find(".dashicons").removeClass("rotate")},hasBlockingError:function(i){return i=void 0!==i&&i,imagifyOptions.bulk.curlMissing?(i&&this.displayError({html:imagifyOptions.bulk.labels.curlMissing}),!0):imagifyOptions.bulk.editorMissing?(i&&this.displayError({html:imagifyOptions.bulk.labels.editorMissing}),!0):imagifyOptions.bulk.extHttpBlocked?(i&&this.displayError({html:imagifyOptions.bulk.labels.extHttpBlocked}),!0):imagifyOptions.bulk.apiDown?(i&&this.displayError({html:imagifyOptions.bulk.labels.apiDown}),!0):imagifyOptions.bulk.keyIsValid?!!imagifyOptions.bulk.isOverQuota&&(i&&this.displayError({title:imagifyOptions.bulk.labels.overQuotaTitle,html:s("#tmpl-imagify-overquota-alert").html(),type:"info",customClass:"imagify-swal-has-subtitle imagify-swal-error-header",showConfirmButton:!1}),!0):(i&&this.displayError({title:imagifyOptions.bulk.labels.invalidAPIKeyTitle,type:"info"}),!0)},displayError:function(i,e,t){var a={title:"",html:"",type:"error",customClass:"",width:620,padding:0,showCloseButton:!0,showConfirmButton:!0};(t=s.isPlainObject(i)?s.extend({},a,i):s.extend({},a,{title:i||"",html:e||""},t=t||{})).title=t.title||imagifyOptions.bulk.labels.error,t.customClass+=" imagify-sweet-alert",swal(t).catch(swal.noop)},getAjaxUrl:function(i,e){var t=ajaxurl+a.imagify.concat+"_wpnonce="+imagifyOptions.bulk.ajaxNonce;return(t+="&action="+imagifyOptions.bulk.ajaxActions[i])+("&context="+e.join("_"))},stopProcess:function(i){this.processIsStopped=!0,this.error=i,this.processFinished()}},a.imagify.optionsBulk.init())}(window,document,jQuery),function(n){var a=n.propHooks.checked;n.propHooks.checked={set:function(i,e,t){e=void 0===a?i[t]=e:a(i,e,t);return n(i).trigger("change.imagify"),e}},n(".imagify-select-all").on("click.imagify",function(){var i=n(this),e=i.data("action"),t=i.closest(".imagify-select-all-buttons"),a=t.prev(".imagify-check-group"),s="imagify-is-inactive";if(i.hasClass(s))return!1;t.find(".imagify-select-all").removeClass(s).attr("aria-disabled","false"),i.addClass(s).attr("aria-disabled","true"),a.find(".imagify-row-check").prop("checked",function(){return!n(this).is(":hidden,:disabled")&&"select"===e})}),n(".imagify-check-group .imagify-row-check").on("change.imagify",function(){var i=n(this).closest(".imagify-check-group"),e=i.find(".imagify-row-check"),t=e.filter(":visible:enabled").length,e=e.filter(":visible:enabled:checked").length,i=i.next(".imagify-select-all-buttons"),a="imagify-is-inactive";0===e&&i.find('[data-action="unselect"]').addClass(a).attr("aria-disabled","true"),e===t&&i.find('[data-action="select"]').addClass(a).attr("aria-disabled","true"),e!==t&&0 imagify_can_optimize_custom_folders() ? get_imagify_admin_url( 'get-files-tree' ) : false, - 'labels' => [ + 'getFilesTree' => imagify_can_optimize_custom_folders() ? get_imagify_admin_url( 'get-files-tree' ) : false, + 'resetInternalState' => [ + 'action' => 'imagify_reset_internal_state', + 'confirm' => __( 'Are you sure you want to reset the Imagify internal state? This will clear optimization locks and cancel any pending jobs.', 'imagify' ), + 'success' => __( 'Imagify internal state has been reset successfully.', 'imagify' ), + 'error' => __( 'An error occurred while resetting the internal state. Please try again.', 'imagify' ), + ], + 'labels' => [ 'ValidApiKeyText' => __( 'Your API key is valid.', 'imagify' ), 'waitApiKeyCheckText' => __( 'Check in progress...', 'imagify' ), 'ApiKeyCheckSuccessTitle' => __( 'Congratulations!', 'imagify' ), diff --git a/views/page-settings.php b/views/page-settings.php index 35ed8f62..7decbf86 100755 --- a/views/page-settings.php +++ b/views/page-settings.php @@ -188,6 +188,7 @@ print_template( 'part-settings-tools' ); $this->print_template( 'part-settings-footer' ); } ?> diff --git a/views/part-settings-tools.php b/views/part-settings-tools.php new file mode 100644 index 00000000..740a70cd --- /dev/null +++ b/views/part-settings-tools.php @@ -0,0 +1,16 @@ + +
+

+

+ +

+ + +
From 4c5ef67183e4eadd0859d652176e578dd382aff7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Robin?= Date: Fri, 12 Jun 2026 17:17:42 +0200 Subject: [PATCH 3/6] fix(tools): fix PHPCS doc comment violations in unit tests and add API docs - Add capital-letter short descriptions to all test method doc comments - Add @inheritDoc short descriptions to setUp()/tearDown() doc comments - Add phpcs:ignore for intentional WordPress.WP.GlobalVariablesOverride in test setup - Add docs/api/tools.md documenting the AJAX action, what gets cleared, and multisite notes Co-Authored-By: Claude Sonnet 4.6 --- .../Tools/InternalStateList/sharedList.php | 6 +- .../Tools/ResetInternalState/reset.php | 17 +++-- .../Tools/Subscriber/getSubscribedEvents.php | 6 +- docs/api/tools.md | 75 +++++++++++++++++++ 4 files changed, 92 insertions(+), 12 deletions(-) create mode 100644 docs/api/tools.md diff --git a/Tests/Unit/classes/Tools/InternalStateList/sharedList.php b/Tests/Unit/classes/Tools/InternalStateList/sharedList.php index 532e5010..15acd105 100644 --- a/Tests/Unit/classes/Tools/InternalStateList/sharedList.php +++ b/Tests/Unit/classes/Tools/InternalStateList/sharedList.php @@ -17,7 +17,7 @@ class Test_SharedList extends TestCase { /** - * get_bulk_transients() returns the expected canonical array. + * Tests that get_bulk_transients() returns the expected canonical array. */ public function testGetBulkTransientsReturnsExpectedArray(): void { $expected = [ @@ -60,7 +60,7 @@ public function testGetBulkTransientsDoesNotContainUserCacheTransients(): void { } /** - * get_locked_transient_patterns() returns the expected canonical array. + * Tests that get_locked_transient_patterns() returns the expected canonical array. */ public function testGetLockedTransientPatternsReturnsExpectedArray(): void { $expected = [ @@ -74,7 +74,7 @@ public function testGetLockedTransientPatternsReturnsExpectedArray(): void { } /** - * get_scheduler_hooks() returns the expected canonical array. + * Tests that get_scheduler_hooks() returns the expected canonical array. */ public function testGetSchedulerHooksReturnsExpectedArray(): void { $expected = [ diff --git a/Tests/Unit/classes/Tools/ResetInternalState/reset.php b/Tests/Unit/classes/Tools/ResetInternalState/reset.php index 7f361af9..b2c225db 100644 --- a/Tests/Unit/classes/Tools/ResetInternalState/reset.php +++ b/Tests/Unit/classes/Tools/ResetInternalState/reset.php @@ -24,6 +24,8 @@ class Test_Reset extends TestCase { private $wpdb; /** + * Sets up the test fixture. + * * @inheritDoc */ public function setUp(): void { @@ -34,10 +36,13 @@ public function setUp(): void { $this->wpdb->options = 'wp_options'; $this->wpdb->sitemeta = 'wp_sitemeta'; + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited $GLOBALS['wpdb'] = $this->wpdb; } /** + * Tears down the test fixture. + * * @inheritDoc */ public function tearDown(): void { @@ -47,7 +52,7 @@ public function tearDown(): void { } /** - * reset() calls delete_transient() with every bulk transient name. + * Tests that reset() calls delete_transient() with every bulk transient name. */ public function testDeletesBulkTransients(): void { $this->wpdb->shouldReceive( 'prepare' )->andReturn( 'PREPARED_SQL' ); @@ -81,7 +86,7 @@ function ( string $transient ) use ( &$deleted ) { } /** - * reset() does NOT call delete_transient() for user/account cache transients. + * Tests that reset() does NOT call delete_transient() for user/account cache transients. */ public function testDoesNotDeleteUserCacheTransients(): void { $this->wpdb->shouldReceive( 'prepare' )->andReturn( 'PREPARED_SQL' ); @@ -118,7 +123,7 @@ function ( string $transient ) use ( &$deleted ) { } /** - * reset() issues a $wpdb->query() for each locked transient pattern against wp_options. + * Tests that reset() issues a $wpdb->query() for each locked transient pattern against wp_options. */ public function testRunsLikePatternQueryAgainstOptions(): void { Functions\when( 'delete_transient' )->justReturn( true ); @@ -151,7 +156,7 @@ function ( string $sql, string $pattern ) use ( &$patterns_queried ) { } /** - * reset() runs a second query against sitemeta when is_multisite() is true. + * Tests that reset() runs a second query against sitemeta when is_multisite() is true. */ public function testRunsSitemetaQueryOnMultisite(): void { Functions\when( 'delete_transient' )->justReturn( true ); @@ -179,9 +184,9 @@ function () use ( &$query_calls ) { } /** - * reset() skips as_unschedule_all_actions() when ActionScheduler is not loaded. + * Tests that reset() skips as_unschedule_all_actions() when ActionScheduler is not loaded. * - * as_unschedule_all_actions() does not exist in the test environment, so + * As_unschedule_all_actions() does not exist in the test environment, so * function_exists() naturally returns false — no stubbing needed. * The 4 wpdb::query() calls for the options LIKE patterns are the proof that * reset() ran to completion without errors. diff --git a/Tests/Unit/classes/Tools/Subscriber/getSubscribedEvents.php b/Tests/Unit/classes/Tools/Subscriber/getSubscribedEvents.php index 2e1932d5..8932c56d 100644 --- a/Tests/Unit/classes/Tools/Subscriber/getSubscribedEvents.php +++ b/Tests/Unit/classes/Tools/Subscriber/getSubscribedEvents.php @@ -17,7 +17,7 @@ class Test_GetSubscribedEvents extends TestCase { /** - * get_subscribed_events() registers the AJAX reset action. + * Tests that get_subscribed_events() registers the AJAX reset action. */ public function testRegistersAjaxResetAction(): void { $events = Subscriber::get_subscribed_events(); @@ -27,7 +27,7 @@ public function testRegistersAjaxResetAction(): void { } /** - * get_subscribed_events() does NOT include the imagify_settings_tools hook. + * Tests that get_subscribed_events() does NOT include the imagify_settings_tools hook. * * The settings section is rendered directly via print_template() — no hook needed. */ @@ -38,7 +38,7 @@ public function testDoesNotContainSettingsToolsHook(): void { } /** - * get_subscribed_events() returns exactly one event entry. + * Tests that get_subscribed_events() returns exactly one event entry. */ public function testReturnsExactlyOneEvent(): void { $events = Subscriber::get_subscribed_events(); diff --git a/docs/api/tools.md b/docs/api/tools.md new file mode 100644 index 00000000..b27985f9 --- /dev/null +++ b/docs/api/tools.md @@ -0,0 +1,75 @@ +# Tools Module — Internal State Reset + +## Overview + +The `Imagify\Tools` module provides a one-click tool to reset Imagify's internal optimization state. This is the programmatic equivalent of deactivating and reactivating the plugin — it clears stale transients, process locks, and ActionScheduler jobs that can cause the bulk optimizer to appear stuck. + +Settings, the API key, and user-data caches are intentionally left untouched. + +## Classes + +| Class | Responsibility | +|-------|----------------| +| `Imagify\Tools\InternalStateList` | Single source of truth for the three canonical arrays (bulk transients, lock patterns, scheduler hooks). No dependencies, no constructor — safe to `require_once` from `uninstall.php`. | +| `Imagify\Tools\ResetInternalState` | Service that performs the actual cleanup. Iterates `InternalStateList` and issues the necessary `delete_transient()` calls, raw SQL DELETEs (via `$wpdb->prepare`), and `as_unschedule_all_actions()` calls. | +| `Imagify\Tools\Subscriber` | `SubscriberInterface` implementation that wires the AJAX action. | +| `Imagify\Tools\ServiceProvider` | DI wiring — registered in `config/providers.php`. | + +## AJAX Action + +| Key | Value | +|-----|-------| +| Action name | `imagify_reset_internal_state` | +| Hook | `wp_ajax_imagify_reset_internal_state` | +| Method | POST | +| Nonce key | `_wpnonce` | +| Nonce action | `imagify_reset_internal_state` | +| Capability | `imagify_get_context('wp')->current_user_can('manage')` | + +On success the endpoint returns: + +```json +{ "success": true, "data": { "message": "Imagify internal state has been reset successfully." } } +``` + +On capability failure `imagify_die()` is called (403). On nonce failure `imagify_check_nonce()` dies with the standard WordPress error. + +**Nonce delivery:** The nonce must be sent as `_wpnonce` in the POST body. Sending it as `nonce` causes a silent 403 because `imagify_check_nonce()` delegates to `check_ajax_referer($action, false)`, which checks only `_ajax_nonce` and `_wpnonce`. + +## What Gets Cleared + +### Bulk running-state transients (`InternalStateList::get_bulk_transients()`) + +- `imagify_custom-folders_optimize_running` +- `imagify_wp_optimize_running` +- `imagify_bulk_optimization_complete` +- `imagify_missing_next_gen_total` +- `imagify_bulk_optimization_result` +- `imagify_bulk_optimization_infos` +- `imagify_bulk_optimization_level` (legacy artifact, cleared for hygiene) + +Note: this is a superset of `Bulk::delete_transients_data()`. The last two entries are not cleared by the Bulk deactivation hook. + +### Process-lock LIKE patterns (`InternalStateList::get_locked_transient_patterns()`) + +SQL `DELETE … WHERE option_name LIKE` patterns run against `$wpdb->options`. On multisite a second query runs against `$wpdb->sitemeta`. + +- `\_transient\_%imagify-auto-optimize-%` (legacy) +- `\_transient\_%imagify\_rpc\_%` (legacy) +- `\_transient\_imagify\_%\_process\_locked` +- `\_site\_transient\_imagify\_%\_process\_lock%` + +### ActionScheduler hooks (`InternalStateList::get_scheduler_hooks()`) + +Unscheduled via `as_unschedule_all_actions()` (guarded by `function_exists`): + +- `imagify_optimize_media` +- `imagify_convert_next_gen` + +## Extending the Lists + +Add new items to `InternalStateList` — the single source of truth consulted by both `ResetInternalState` (live reset) and `uninstall.php` (plugin removal). + +## Multisite Notes + +The `$wpdb->options` DELETE runs on every call. The `$wpdb->sitemeta` DELETE is guarded by `is_multisite()`. The reset is scoped to the current site — it does not iterate all network sites. From 497c2fcf5a2aaec18fbd55656f776a36f30f893e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Robin?= Date: Fri, 12 Jun 2026 21:33:29 +0200 Subject: [PATCH 4/6] fix(tools): rename Subscriber callback to reset_internal_state per spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec step 3 mandates the AJAX callback method name is `reset_internal_state`; `handle_reset` was used by mistake. Pure rename — no logic changes. Co-Authored-By: Claude Sonnet 4.6 --- Tests/Unit/classes/Tools/Subscriber/getSubscribedEvents.php | 2 +- classes/Tools/Subscriber.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/Unit/classes/Tools/Subscriber/getSubscribedEvents.php b/Tests/Unit/classes/Tools/Subscriber/getSubscribedEvents.php index 8932c56d..efcfcb53 100644 --- a/Tests/Unit/classes/Tools/Subscriber/getSubscribedEvents.php +++ b/Tests/Unit/classes/Tools/Subscriber/getSubscribedEvents.php @@ -23,7 +23,7 @@ public function testRegistersAjaxResetAction(): void { $events = Subscriber::get_subscribed_events(); $this->assertArrayHasKey( 'wp_ajax_imagify_reset_internal_state', $events ); - $this->assertSame( 'handle_reset', $events['wp_ajax_imagify_reset_internal_state'] ); + $this->assertSame( 'reset_internal_state', $events['wp_ajax_imagify_reset_internal_state'] ); } /** diff --git a/classes/Tools/Subscriber.php b/classes/Tools/Subscriber.php index 61638bf3..9fb1d4d6 100644 --- a/classes/Tools/Subscriber.php +++ b/classes/Tools/Subscriber.php @@ -37,7 +37,7 @@ public function __construct( ResetInternalState $reset ) { public static function get_subscribed_events(): array { return [ // @action - 'wp_ajax_imagify_reset_internal_state' => 'handle_reset', + 'wp_ajax_imagify_reset_internal_state' => 'reset_internal_state', ]; } @@ -48,7 +48,7 @@ public static function get_subscribed_events(): array { * * @return void */ - public function handle_reset(): void { + public function reset_internal_state(): void { imagify_check_nonce( 'imagify_reset_internal_state' ); if ( ! imagify_get_context( 'wp' )->current_user_can( 'manage' ) ) { From ccfaac3af90c6e8c4079f178c4c56690138b2f04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Robin?= Date: Mon, 15 Jun 2026 00:53:20 +0200 Subject: [PATCH 5/6] fix(tools): use esc_like() for LIKE patterns and add coverage for classes/ - InternalStateList::get_locked_transient_patterns() now returns plain (unescaped) strings; ResetInternalState::reset() assembles safe LIKE patterns via esc_like() on each segment, fixing the pre-escaped pattern issue raised in the lead-reviewer HIGH finding. - phpunit.xml.dist coverage include now covers classes/ alongside inc/, so Codacy diff coverage reports on new PSR-4 code. - Add Test_ResetInternalState test for Subscriber::reset_internal_state() covering the authorized and unauthorized AJAX handler paths. - Update existing reset.php tests to mock esc_like() on $wpdb. Co-Authored-By: Claude Sonnet 4.6 --- .../Tools/InternalStateList/sharedList.php | 10 ++-- .../Tools/ResetInternalState/reset.php | 35 ++++++++++-- .../Tools/Subscriber/resetInternalState.php | 54 +++++++++++++++++++ Tests/Unit/phpunit.xml.dist | 1 + classes/Tools/InternalStateList.php | 16 +++--- classes/Tools/ResetInternalState.php | 6 ++- 6 files changed, 105 insertions(+), 17 deletions(-) create mode 100644 Tests/Unit/classes/Tools/Subscriber/resetInternalState.php diff --git a/Tests/Unit/classes/Tools/InternalStateList/sharedList.php b/Tests/Unit/classes/Tools/InternalStateList/sharedList.php index 15acd105..792abae9 100644 --- a/Tests/Unit/classes/Tools/InternalStateList/sharedList.php +++ b/Tests/Unit/classes/Tools/InternalStateList/sharedList.php @@ -60,14 +60,14 @@ public function testGetBulkTransientsDoesNotContainUserCacheTransients(): void { } /** - * Tests that get_locked_transient_patterns() returns the expected canonical array. + * Tests that get_locked_transient_patterns() returns plain (unescaped) LIKE templates. */ public function testGetLockedTransientPatternsReturnsExpectedArray(): void { $expected = [ - '\_transient\_%imagify-auto-optimize-%', - '\_transient\_%imagify\_rpc\_%', - '\_transient\_imagify\_%\_process\_locked', - '\_site\_transient\_imagify\_%\_process\_lock%', + '_transient_%imagify-auto-optimize-%', + '_transient_%imagify_rpc_%', + '_transient_imagify_%_process_locked', + '_site_transient_imagify_%_process_lock%', ]; $this->assertSame( $expected, InternalStateList::get_locked_transient_patterns() ); diff --git a/Tests/Unit/classes/Tools/ResetInternalState/reset.php b/Tests/Unit/classes/Tools/ResetInternalState/reset.php index b2c225db..4aaffdf2 100644 --- a/Tests/Unit/classes/Tools/ResetInternalState/reset.php +++ b/Tests/Unit/classes/Tools/ResetInternalState/reset.php @@ -55,6 +55,11 @@ public function tearDown(): void { * Tests that reset() calls delete_transient() with every bulk transient name. */ public function testDeletesBulkTransients(): void { + $this->wpdb->shouldReceive( 'esc_like' )->andReturnUsing( + function ( string $value ): string { + return str_replace( [ '\\', '%', '_' ], [ '\\\\', '\\%', '\\_' ], $value ); + } + ); $this->wpdb->shouldReceive( 'prepare' )->andReturn( 'PREPARED_SQL' ); $this->wpdb->shouldReceive( 'query' )->andReturn( 1 ); @@ -89,6 +94,11 @@ function ( string $transient ) use ( &$deleted ) { * Tests that reset() does NOT call delete_transient() for user/account cache transients. */ public function testDoesNotDeleteUserCacheTransients(): void { + $this->wpdb->shouldReceive( 'esc_like' )->andReturnUsing( + function ( string $value ): string { + return str_replace( [ '\\', '%', '_' ], [ '\\\\', '\\%', '\\_' ], $value ); + } + ); $this->wpdb->shouldReceive( 'prepare' )->andReturn( 'PREPARED_SQL' ); $this->wpdb->shouldReceive( 'query' )->andReturn( 1 ); @@ -123,12 +133,20 @@ function ( string $transient ) use ( &$deleted ) { } /** - * Tests that reset() issues a $wpdb->query() for each locked transient pattern against wp_options. + * Tests that reset() builds LIKE patterns via esc_like() and issues a query for each against wp_options. */ public function testRunsLikePatternQueryAgainstOptions(): void { Functions\when( 'delete_transient' )->justReturn( true ); Functions\when( 'is_multisite' )->justReturn( false ); + // Simulate esc_like(): escape \, %, and _ with a leading backslash. + $this->wpdb->shouldReceive( 'esc_like' ) + ->andReturnUsing( + function ( string $value ): string { + return str_replace( [ '\\', '%', '_' ], [ '\\\\', '\\%', '\\_' ], $value ); + } + ); + $patterns_queried = []; $this->wpdb->shouldReceive( 'prepare' ) @@ -143,6 +161,7 @@ function ( string $sql, string $pattern ) use ( &$patterns_queried ) { ( new ResetInternalState() )->reset(); + // Expected: raw pattern parts esc_like'd, reassembled with % wildcards. $expected_patterns = [ '\_transient\_%imagify-auto-optimize-%', '\_transient\_%imagify\_rpc\_%', @@ -162,9 +181,13 @@ public function testRunsSitemetaQueryOnMultisite(): void { Functions\when( 'delete_transient' )->justReturn( true ); Functions\when( 'is_multisite' )->justReturn( true ); + // Accept any esc_like() call (options-pattern parts + the explicit sitemeta prefix). $this->wpdb->shouldReceive( 'esc_like' ) - ->with( '_site_transient_imagify_' ) - ->andReturn( '_site_transient_imagify_' ); + ->andReturnUsing( + function ( string $value ): string { + return str_replace( [ '\\', '%', '_' ], [ '\\\\', '\\%', '\\_' ], $value ); + } + ); $this->wpdb->shouldReceive( 'prepare' )->andReturn( 'PREPARED_SQL' ); @@ -192,6 +215,12 @@ function () use ( &$query_calls ) { * reset() ran to completion without errors. */ public function testSkipsSchedulerWhenFunctionNotExists(): void { + $this->wpdb->shouldReceive( 'esc_like' ) + ->andReturnUsing( + function ( string $value ): string { + return str_replace( [ '\\', '%', '_' ], [ '\\\\', '\\%', '\\_' ], $value ); + } + ); $this->wpdb->shouldReceive( 'prepare' )->andReturn( 'PREPARED_SQL' ); $query_calls = 0; diff --git a/Tests/Unit/classes/Tools/Subscriber/resetInternalState.php b/Tests/Unit/classes/Tools/Subscriber/resetInternalState.php new file mode 100644 index 00000000..5c2fea7e --- /dev/null +++ b/Tests/Unit/classes/Tools/Subscriber/resetInternalState.php @@ -0,0 +1,54 @@ +shouldReceive( 'current_user_can' )->with( 'manage' )->andReturn( true ); + + Functions\when( 'imagify_check_nonce' )->justReturn(); + Functions\when( 'imagify_get_context' )->justReturn( $context ); + Functions\when( 'wp_send_json_success' )->justReturn(); + Functions\when( '__' )->returnArg(); + + $reset_service = Mockery::mock( ResetInternalState::class ); + $reset_service->shouldReceive( 'reset' )->once(); + + ( new Subscriber( $reset_service ) )->reset_internal_state(); + } + + /** + * Tests that reset_internal_state() calls imagify_die() and skips reset() for unauthorized users. + */ + public function testCallsImageDieAndSkipsResetForUnauthorizedUser(): void { + $context = Mockery::mock( 'stdClass' ); + $context->shouldReceive( 'current_user_can' )->with( 'manage' )->andReturn( false ); + + Functions\when( 'imagify_check_nonce' )->justReturn(); + Functions\when( 'imagify_get_context' )->justReturn( $context ); + Functions\when( 'imagify_die' )->justReturn(); + + $reset_service = Mockery::mock( ResetInternalState::class ); + $reset_service->shouldNotReceive( 'reset' ); + + ( new Subscriber( $reset_service ) )->reset_internal_state(); + } +} diff --git a/Tests/Unit/phpunit.xml.dist b/Tests/Unit/phpunit.xml.dist index 31a15118..f1ffb069 100644 --- a/Tests/Unit/phpunit.xml.dist +++ b/Tests/Unit/phpunit.xml.dist @@ -3,6 +3,7 @@ ../../inc + ../../classes diff --git a/classes/Tools/InternalStateList.php b/classes/Tools/InternalStateList.php index 29475f67..b38beee2 100644 --- a/classes/Tools/InternalStateList.php +++ b/classes/Tools/InternalStateList.php @@ -49,19 +49,21 @@ public static function get_bulk_transients(): array { } /** - * Returns LIKE patterns for process-lock and legacy RPC transients. + * Returns raw LIKE pattern templates for process-lock and legacy RPC transients. * - * Used in a raw SQL DELETE against $wpdb->options (and $wpdb->sitemeta on - * multisite). Patterns follow the format expected by $wpdb->prepare with %s. + * These are plain strings where `%` is a wildcard placeholder and `_` is a + * literal underscore. Callers must pass each part through $wpdb->esc_like() + * before assembling the final LIKE expression — do NOT use these strings + * directly as SQL LIKE patterns. * * @return array */ public static function get_locked_transient_patterns(): array { return [ - '\_transient\_%imagify-auto-optimize-%', // Legacy/deprecated, retained for hygiene on older installs. - '\_transient\_%imagify\_rpc\_%', // Legacy/deprecated. - '\_transient\_imagify\_%\_process\_locked', - '\_site\_transient\_imagify\_%\_process\_lock%', + '_transient_%imagify-auto-optimize-%', // Legacy/deprecated, retained for hygiene on older installs. + '_transient_%imagify_rpc_%', // Legacy/deprecated. + '_transient_imagify_%_process_locked', + '_site_transient_imagify_%_process_lock%', ]; } diff --git a/classes/Tools/ResetInternalState.php b/classes/Tools/ResetInternalState.php index bd220f31..3757a9eb 100644 --- a/classes/Tools/ResetInternalState.php +++ b/classes/Tools/ResetInternalState.php @@ -33,11 +33,13 @@ public function reset(): void { } // 2. Delete process-lock and legacy RPC transients via LIKE patterns in wp_options. - foreach ( InternalStateList::get_locked_transient_patterns() as $pattern ) { + foreach ( InternalStateList::get_locked_transient_patterns() as $raw_pattern ) { + // Build a safe LIKE pattern: esc_like() each literal segment, preserve % wildcards. + $like_pattern = implode( '%', array_map( [ $wpdb, 'esc_like' ], explode( '%', $raw_pattern ) ) ); $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.LikeWildcardsInQuery - $pattern + $like_pattern ) ); } From da413e648834e5c5e49a7930275af641912214d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Robin?= Date: Mon, 15 Jun 2026 16:25:36 +0200 Subject: [PATCH 6/6] fix(ui): add line-height to reset internal state confirmation popup title --- assets/css/sweetalert-custom.css | 1 + assets/css/sweetalert-custom.min.css | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/assets/css/sweetalert-custom.css b/assets/css/sweetalert-custom.css index 6e0bd469..c4dbb324 100755 --- a/assets/css/sweetalert-custom.css +++ b/assets/css/sweetalert-custom.css @@ -32,6 +32,7 @@ body[class*="_imagify"] .swal2-container.swal2-shown { margin: 0; padding: 28px 32px; font-size: 24px; + line-height: 1; text-align: center; color: #FFF; background: #1F2332; diff --git a/assets/css/sweetalert-custom.min.css b/assets/css/sweetalert-custom.min.css index 660f01e9..98e20173 100755 --- a/assets/css/sweetalert-custom.min.css +++ b/assets/css/sweetalert-custom.min.css @@ -1 +1 @@ -body[class*="_imagify"] .swal2-container.swal2-shown{background:rgb(31,35 ,50);background:rgba(31,35 ,50,.9);z-index:100000}.imagify-sweet-alert .swal2-modal{border-radius:2px}.imagify-sweet-alert{background:#1f2332!important}.imagify-sweet-alert .swal2-icon{margin-bottom:5px}.imagify-swal-error-header{background:#c51162!important}.imagify-swal-error-header .swal2-icon{border-color:#fff;color:#fff}.imagify-sweet-alert .swal2-title{margin:0;padding:28px 32px;font-size:24px;text-align:center;color:#fff;background:#1f2332}.imagify-swal-has-subtitle .swal2-title{text-align:left}.imagify-swal-error-header .swal2-title{background:#c51162;text-align:center;line-height:1.15}.imagify-sweet-alert .imagify-swal-subtitle{padding:0 32px 28px;margin-top:-16px;font-weight:500;font-size:14px;text-align:left;color:#7a8996;background:#1f2332}.imagify-swal-error-header .imagify-swal-subtitle{color:#fff;background:#c51162;text-align:center}.imagify-swal-buttonswrapper,.imagify-sweet-alert .swal2-buttonswrapper{margin-top:0;padding:22px;background:#f4f7f9}.imagify-swal-buttonswrapper a.button.imagify-button-primary,.imagify-sweet-alert button.swal2-styled{height:auto;padding:12px 32px;margin:10px;font-size:14px;letter-spacing:1px;text-transform:uppercase;border-radius:3px;background-color:#40b1d0!important;text-shadow:none!important;-webkit-box-shadow:0 3px 0 #338ea6;box-shadow:0 3px 0 #338ea6;white-space:normal;line-height:1.5}.imagify-swal-buttonswrapper a.button.imagify-button-primary:focus,.imagify-swal-buttonswrapper a.button.imagify-button-primary:hover{text-shadow:none;color:#fff}.imagify-swal-buttonswrapper a.button svg{margin-right:12px;vertical-align:-2px}.imagify-sweet-alert button.loading{border-radius:100%!important;height:40px!important;padding:0!important;-webkit-box-shadow:none!important;box-shadow:none!important}.imagify-sweet-alert button.swal2-cancel{color:#7a8996;background:#e9eff2!important;-webkit-box-shadow:0 3px 0 rgba(31,35,50,.2);box-shadow:0 3px 0 rgba(31,35,50,.2)}.imagify-sweet-alert-signup.imagify-sweet-alert{background:#fff!important}.imagify-sweet-alert-signup .swal2-buttonswrapper{padding:12px 22px}.swal2-success-circular-line-left,.swal2-success-circular-line-right,.swal2-success-fix{background:#1f2332!important}.imagify-sweet-alert-signup .sa-confirm-button-container{width:40%}.imagify-sweet-alert-signup .swal2-input{margin-top:0;margin-left:40px;margin-right:40px;width:calc(100% - 80px)}.imagify-sweet-alert .la-ball-fall,.imagify-sweet-alert .sa-input-error:after,.imagify-sweet-alert .sa-input-error:before{top:25%!important}.imagify-sweet-alert .swal2-buttonswrapper.swal2-loading .swal2-confirm.swal2-confirm{height:40px!important;border-radius:100%!important;border-left-width:0!important;border-right-width:0!important}.imagify-sweet-alert .swal2-content{padding:28px 32px;background:#fff}.imagify-swal-has-subtitle .swal2-content{padding:0}.imagify-swal-content{font-size:14px;padding:28px 32px}.imagify-swal-quota .imagify-space-left{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between;padding:4px 32px;text-align:left;font-weight:700;color:#fff;background:#343a49}.imagify-swal-quota .imagify-space-left p{font-size:14px}.imagify-swal-quota .imagify-space-left [class^=imagify-bar-]{width:auto;-ms-flex-preferred-size:269px;flex-basis:269px}.imagify-sweet-alert .swal2-close{color:rgba(255,255,255,.5)} \ No newline at end of file +body[class*="_imagify"] .swal2-container.swal2-shown{background:rgb(31,35 ,50);background:rgba(31,35 ,50,.9);z-index:100000}.imagify-sweet-alert .swal2-modal{border-radius:2px}.imagify-sweet-alert{background:#1f2332!important}.imagify-sweet-alert .swal2-icon{margin-bottom:5px}.imagify-swal-error-header{background:#c51162!important}.imagify-swal-error-header .swal2-icon{border-color:#fff;color:#fff}.imagify-sweet-alert .swal2-title{margin:0;padding:28px 32px;font-size:24px;line-height:1;text-align:center;color:#fff;background:#1f2332}.imagify-swal-has-subtitle .swal2-title{text-align:left}.imagify-swal-error-header .swal2-title{background:#c51162;text-align:center;line-height:1.15}.imagify-sweet-alert .imagify-swal-subtitle{padding:0 32px 28px;margin-top:-16px;font-weight:500;font-size:14px;text-align:left;color:#7a8996;background:#1f2332}.imagify-swal-error-header .imagify-swal-subtitle{color:#fff;background:#c51162;text-align:center}.imagify-swal-buttonswrapper,.imagify-sweet-alert .swal2-buttonswrapper{margin-top:0;padding:22px;background:#f4f7f9}.imagify-swal-buttonswrapper a.button.imagify-button-primary,.imagify-sweet-alert button.swal2-styled{height:auto;padding:12px 32px;margin:10px;font-size:14px;letter-spacing:1px;text-transform:uppercase;border-radius:3px;background-color:#40b1d0!important;text-shadow:none!important;-webkit-box-shadow:0 3px 0 #338ea6;box-shadow:0 3px 0 #338ea6;white-space:normal;line-height:1.5}.imagify-swal-buttonswrapper a.button.imagify-button-primary:focus,.imagify-swal-buttonswrapper a.button.imagify-button-primary:hover{text-shadow:none;color:#fff}.imagify-swal-buttonswrapper a.button svg{margin-right:12px;vertical-align:-2px}.imagify-sweet-alert button.loading{border-radius:100%!important;height:40px!important;padding:0!important;-webkit-box-shadow:none!important;box-shadow:none!important}.imagify-sweet-alert button.swal2-cancel{color:#7a8996;background:#e9eff2!important;-webkit-box-shadow:0 3px 0 rgba(31,35,50,.2);box-shadow:0 3px 0 rgba(31,35,50,.2)}.imagify-sweet-alert-signup.imagify-sweet-alert{background:#fff!important}.imagify-sweet-alert-signup .swal2-buttonswrapper{padding:12px 22px}.swal2-success-circular-line-left,.swal2-success-circular-line-right,.swal2-success-fix{background:#1f2332!important}.imagify-sweet-alert-signup .sa-confirm-button-container{width:40%}.imagify-sweet-alert-signup .swal2-input{margin-top:0;margin-left:40px;margin-right:40px;width:calc(100% - 80px)}.imagify-sweet-alert .la-ball-fall,.imagify-sweet-alert .sa-input-error:after,.imagify-sweet-alert .sa-input-error:before{top:25%!important}.imagify-sweet-alert .swal2-buttonswrapper.swal2-loading .swal2-confirm.swal2-confirm{height:40px!important;border-radius:100%!important;border-left-width:0!important;border-right-width:0!important}.imagify-sweet-alert .swal2-content{padding:28px 32px;background:#fff}.imagify-swal-has-subtitle .swal2-content{padding:0}.imagify-swal-content{font-size:14px;padding:28px 32px}.imagify-swal-quota .imagify-space-left{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between;padding:4px 32px;text-align:left;font-weight:700;color:#fff;background:#343a49}.imagify-swal-quota .imagify-space-left p{font-size:14px}.imagify-swal-quota .imagify-space-left [class^=imagify-bar-]{width:auto;-ms-flex-preferred-size:269px;flex-basis:269px}.imagify-sweet-alert .swal2-close{color:rgba(255,255,255,.5)} \ No newline at end of file