diff --git a/includes/Settings/Settings_Page.php b/includes/Settings/Settings_Page.php index a29c4fc56..134ef3ffa 100644 --- a/includes/Settings/Settings_Page.php +++ b/includes/Settings/Settings_Page.php @@ -14,6 +14,7 @@ use WordPress\AI\Experiments\Experiment_Category; use WordPress\AI\Features\Feature_Category; use WordPress\AI\Features\Registry; + use function WordPress\AI\has_ai_credentials; use function WordPress\AI\has_valid_ai_credentials; @@ -27,6 +28,16 @@ */ class Settings_Page { + /** + * Legacy settings page slug. + * TODO: either once [0.6.0 is less than 10% of installs](https://wordpress.org/plugins/ai/advanced/) or we're in October 2026 let's remove this section in case other plugin(s) are attempting to use the `ai` page. + * + * @since x.x.x + * + * @var string + */ + private const LEGACY_PAGE_SLUG = 'ai'; + /** * The settings page slug. * @@ -45,6 +56,9 @@ class Settings_Page { * @return void */ public static function init( Registry $registry ): void { + add_action( 'admin_init', array( self::class, 'maybe_redirect_legacy_page' ), 1 ); + add_action( 'admin_page_access_denied', array( self::class, 'maybe_redirect_legacy_page' ) ); + if ( function_exists( 'ai_ai_wp_admin_render_page' ) ) { add_action( 'admin_menu', @@ -92,6 +106,34 @@ static function () { } } + /** + * Redirects legacy settings page slug to the current settings route. + * + * @since x.x.x + */ + public static function maybe_redirect_legacy_page(): void { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading query param for admin page detection only, no data processing. + if ( ! isset( $_GET['page'] ) ) { + return; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading query param for admin page detection only, no data processing. + $page = sanitize_key( wp_unslash( (string) $_GET['page'] ) ); + if ( self::LEGACY_PAGE_SLUG !== $page ) { + return; + } + + $redirect_url = add_query_arg( + 'page', + self::PAGE_SLUG, + admin_url( 'options-general.php' ) + ); + + if ( wp_safe_redirect( $redirect_url, 301, 'WordPress AI plugin' ) ) { + exit; + } + } + /** * Gets feature group metadata for the settings UI. * diff --git a/tests/Integration/Includes/Settings/Settings_PageTest.php b/tests/Integration/Includes/Settings/Settings_PageTest.php index 1bfc37f0b..05f5d6868 100644 --- a/tests/Integration/Includes/Settings/Settings_PageTest.php +++ b/tests/Integration/Includes/Settings/Settings_PageTest.php @@ -238,6 +238,8 @@ public function setUp(): void { public function tearDown(): void { remove_all_filters( 'wpai_settings_feature_groups' ); remove_all_filters( 'wpai_settings_feature_metadata' ); + remove_all_filters( 'wp_redirect' ); + $_GET = array(); parent::tearDown(); } @@ -486,4 +488,43 @@ public function test_feature_without_settings_has_empty_settings_fields() { $this->assertArrayHasKey( 'settingsFields', $feature ); $this->assertSame( array(), $feature['settingsFields'], 'Feature without custom settings should have empty settingsFields' ); } + + /** + * Test that init registers an admin redirect hook for the legacy settings slug. + */ + public function test_init_registers_legacy_settings_redirect_hook() { + Settings_Page::init( $this->registry ); + + $this->assertTrue( + has_action( 'admin_init', array( Settings_Page::class, 'maybe_redirect_legacy_page' ) ) !== false + ); + $this->assertTrue( + has_action( 'admin_page_access_denied', array( Settings_Page::class, 'maybe_redirect_legacy_page' ) ) !== false + ); + } + + /** + * Test that the legacy settings slug redirects to the new slug. + */ + public function test_legacy_settings_slug_redirects_to_new_slug() { + $captured_location = null; + $captured_status = null; + + add_filter( + 'wp_redirect', + static function ( $location, $status ) use ( &$captured_location, &$captured_status ) { + $captured_location = $location; + $captured_status = $status; + return false; + }, + 10, + 2 + ); + + $_GET['page'] = 'ai'; + Settings_Page::maybe_redirect_legacy_page(); + + $this->assertSame( admin_url( 'options-general.php?page=ai-wp-admin' ), $captured_location ); + $this->assertSame( 301, $captured_status ); + } }