diff --git a/includes/Abilities/Gated/Gated_Abilities.php b/includes/Abilities/Gated/Gated_Abilities.php new file mode 100644 index 000000000..c38495886 --- /dev/null +++ b/includes/Abilities/Gated/Gated_Abilities.php @@ -0,0 +1,103 @@ +> + */ + private const GATED_ABILITY_CLASSES = array( // phpcs:ignore SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition -- This is used as an array const. + Post_Utilities::class, + Read_Settings::class, + Read_Users::class, + Read_Content::class, + ); + + /** + * Gets all registered gated ability instances. + * + * @since x.x.x + * + * @return array The gated ability instances. + */ + public static function get_all(): array { + /** + * Filters the list of gated ability classes. + * + * Allows developers to add, remove, or replace the abilities gated behind + * the Custom Abilities experiment. + * + * @since x.x.x + * + * @param array> $classes Gated ability class names. + */ + $items = apply_filters( 'wpai_gated_abilities', self::GATED_ABILITY_CLASSES ); + + $abilities = array(); + foreach ( array_unique( (array) $items ) as $item ) { + if ( ! is_string( $item ) ) { + _doing_it_wrong( + __METHOD__, + esc_html__( 'Attempted to register an invalid gated ability. Gated abilities must be class-strings.', 'ai' ), + 'x.x.x' + ); + continue; + } + + if ( ! is_a( $item, Abstract_Gated_Ability::class, true ) ) { + _doing_it_wrong( + __METHOD__, + esc_html__( 'Attempted to register an invalid gated ability. All gated abilities must extend Abstract_Gated_Ability.', 'ai' ), + 'x.x.x' + ); + continue; + } + + try { + $abilities[] = new $item(); + } catch ( Throwable $e ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: 1: Gated ability class name, 2: Error message. */ + esc_html__( 'Failed to instantiate gated ability "%1$s": %2$s', 'ai' ), + esc_html( $item ), + esc_html( $e->getMessage() ) + ), + 'x.x.x' + ); + continue; + } + } + + return $abilities; + } +} diff --git a/includes/Abilities/Gated/Post_Utilities.php b/includes/Abilities/Gated/Post_Utilities.php new file mode 100644 index 000000000..8f0a5803e --- /dev/null +++ b/includes/Abilities/Gated/Post_Utilities.php @@ -0,0 +1,31 @@ +register(); + } +} diff --git a/includes/Abilities/Gated/Read_Content.php b/includes/Abilities/Gated/Read_Content.php new file mode 100644 index 000000000..cd56ae0dd --- /dev/null +++ b/includes/Abilities/Gated/Read_Content.php @@ -0,0 +1,38 @@ +init(); + } +} diff --git a/includes/Abilities/Gated/Read_Settings.php b/includes/Abilities/Gated/Read_Settings.php new file mode 100644 index 000000000..10e189517 --- /dev/null +++ b/includes/Abilities/Gated/Read_Settings.php @@ -0,0 +1,38 @@ +init(); + } +} diff --git a/includes/Abilities/Gated/Read_Users.php b/includes/Abilities/Gated/Read_Users.php new file mode 100644 index 000000000..4bcff080b --- /dev/null +++ b/includes/Abilities/Gated/Read_Users.php @@ -0,0 +1,31 @@ +init(); + } +} diff --git a/includes/Abilities/Utilities/Posts.php b/includes/Abilities/Utilities/Posts.php index 6e76b2886..edc833e5f 100644 --- a/includes/Abilities/Utilities/Posts.php +++ b/includes/Abilities/Utilities/Posts.php @@ -110,62 +110,9 @@ private function register_get_post_details_ability(): void { ), ), 'execute_callback' => static function ( array $input ) { - $post_id = absint( $input['post_id'] ); - $post = self::get_post_object( $post_id ); - - // If the post doesn't exist, return an error. - if ( is_wp_error( $post ) ) { - return $post; - } - - // See if we have specific fields to get or default to all fields. - $fields = isset( $input['fields'] ) && ! empty( $input['fields'] ) ? (array) $input['fields'] : self::$post_details_fields; - - $details = array(); - - if ( in_array( 'content', $fields, true ) ) { - $details['content'] = $post->post_content; - } - - if ( in_array( 'title', $fields, true ) ) { - $details['title'] = $post->post_title; - } - - if ( in_array( 'slug', $fields, true ) ) { - $details['slug'] = $post->post_name; - } - - if ( in_array( 'author', $fields, true ) ) { - // Get the author display name. - $author = get_user_by( 'ID', $post->post_author ); - if ( $author ) { - $details['author'] = $author->display_name; - } else { - $details['author'] = ''; - } - } - - if ( in_array( 'type', $fields, true ) ) { - $details['type'] = $post->post_type; - } - - if ( in_array( 'excerpt', $fields, true ) ) { - $details['excerpt'] = $post->post_excerpt; - } - - /** - * Filters the post details returned by the get-post-details ability. - * - * @since 0.7.0 - * - * @param array $details The post details. - * @param int $post_id The post ID. - * @param array $fields The requested fields. - */ - $details = apply_filters( 'wpai_get_post_details', $details, $post_id, $fields ); - - // Return the post details. - return $details; + $fields = isset( $input['fields'] ) && ! empty( $input['fields'] ) ? (array) $input['fields'] : array(); + + return self::get_post_details( absint( $input['post_id'] ), $fields ); }, 'permission_callback' => array( $this, 'permission_callback' ), 'meta' => array( @@ -255,77 +202,7 @@ private function register_get_terms_ability(): void { ), ), 'execute_callback' => static function ( array $input ) { - $post_id = absint( $input['post_id'] ); - $post = self::get_post_object( $post_id ); - - if ( is_wp_error( $post ) ) { - return $post; - } - - // See if we have a specific taxonomy to get terms for. - $taxonomy = $input['taxonomy'] ?? ''; - - if ( $taxonomy ) { - // If a taxonomy is provided, ensure it exists. - $taxonomy = get_taxonomy( $taxonomy ); - if ( ! $taxonomy ) { - return new WP_Error( - 'taxonomy_not_found', - esc_html__( 'Taxonomy not found.', 'ai' ) - ); - } - $taxonomies = array( $taxonomy ); - } else { - $taxonomies = get_object_taxonomies( $post->post_type, 'objects' ); - } - - // Remove any taxonomies that are not allowed. - $allowed_taxonomies = array(); - foreach ( $taxonomies as $taxonomy ) { - // If the taxonomy is not allowed in REST endpoints, skip it. - if ( empty( $taxonomy->show_in_rest ) ) { - continue; - } - - // If the requested post isn't associated with this taxonomy, skip it. - if ( ! is_object_in_taxonomy( $post->post_type, $taxonomy->name ) ) { - continue; - } - - $allowed_taxonomies[] = $taxonomy->name; - } - - $terms = wp_get_object_terms( $post_id, $allowed_taxonomies ); - - if ( is_wp_error( $terms ) ) { - return new WP_Error( - 'get_terms_error', - /* translators: %1$s: Error message. */ - sprintf( esc_html__( 'Error getting terms: %1$s', 'ai' ), $terms->get_error_message() ) - ); - } - - /** - * Filters the terms returned by the get-post-terms ability. - * - * @since 0.7.0 - * - * @param array<\WP_Term> $terms The terms assigned to the post. - * @param int $post_id The post ID. - * @param array $allowed_taxonomies The allowed taxonomy names. - */ - $terms = apply_filters( 'wpai_get_post_terms', $terms, $post_id, $allowed_taxonomies ); - - return array_map( - static function ( $term ): array { - if ( $term instanceof \WP_Term ) { - return $term->to_array(); - } - - return (array) $term; - }, - $terms - ); + return self::get_post_terms( absint( $input['post_id'] ), (string) ( $input['taxonomy'] ?? '' ) ); }, 'permission_callback' => array( $this, 'permission_callback' ), 'meta' => array( @@ -359,6 +236,153 @@ public function permission_callback( array $args ) { return current_user_can( 'edit_post', $post_id ); } + /** + * Gets the details of a post. + * + * Shared by the `ai/get-post-details` ability and internal callers such as + * get_post_context(), so the data remains available even when the ability + * itself is gated off and not registered. + * + * @since x.x.x + * + * @param int $post_id The ID of the post to get the details of. + * @param array $fields The fields to return. Defaults to all supported fields. + * @return array|\WP_Error The post details, or WP_Error if the post doesn't exist. + */ + public static function get_post_details( int $post_id, array $fields = array() ) { + $post = self::get_post_object( $post_id ); + + // If the post doesn't exist, return an error. + if ( is_wp_error( $post ) ) { + return $post; + } + + // Default to all supported fields when none are specified. + $fields = ! empty( $fields ) ? $fields : self::$post_details_fields; + + $details = array(); + + if ( in_array( 'content', $fields, true ) ) { + $details['content'] = $post->post_content; + } + + if ( in_array( 'title', $fields, true ) ) { + $details['title'] = $post->post_title; + } + + if ( in_array( 'slug', $fields, true ) ) { + $details['slug'] = $post->post_name; + } + + if ( in_array( 'author', $fields, true ) ) { + // Get the author display name. + $author = get_user_by( 'ID', $post->post_author ); + $details['author'] = $author ? $author->display_name : ''; + } + + if ( in_array( 'type', $fields, true ) ) { + $details['type'] = $post->post_type; + } + + if ( in_array( 'excerpt', $fields, true ) ) { + $details['excerpt'] = $post->post_excerpt; + } + + /** + * Filters the post details returned by the get-post-details ability. + * + * @since 0.7.0 + * + * @param array $details The post details. + * @param int $post_id The post ID. + * @param array $fields The requested fields. + */ + return apply_filters( 'wpai_get_post_details', $details, $post_id, $fields ); + } + + /** + * Gets the terms assigned to a post. + * + * Shared by the `ai/get-post-terms` ability and internal callers such as + * get_post_context(), so the data remains available even when the ability + * itself is gated off and not registered. + * + * @since x.x.x + * + * @param int $post_id The ID of the post to get the terms of. + * @param string $taxonomy Optional taxonomy to filter the terms by. + * @return array>|\WP_Error The post terms, or WP_Error on failure. + */ + public static function get_post_terms( int $post_id, string $taxonomy = '' ) { + $post = self::get_post_object( $post_id ); + + if ( is_wp_error( $post ) ) { + return $post; + } + + if ( $taxonomy ) { + // If a taxonomy is provided, ensure it exists. + $taxonomy_object = get_taxonomy( $taxonomy ); + if ( ! $taxonomy_object ) { + return new WP_Error( + 'taxonomy_not_found', + esc_html__( 'Taxonomy not found.', 'ai' ) + ); + } + $taxonomies = array( $taxonomy_object ); + } else { + $taxonomies = get_object_taxonomies( $post->post_type, 'objects' ); + } + + // Remove any taxonomies that are not allowed. + $allowed_taxonomies = array(); + foreach ( $taxonomies as $taxonomy_object ) { + // If the taxonomy is not allowed in REST endpoints, skip it. + if ( empty( $taxonomy_object->show_in_rest ) ) { + continue; + } + + // If the requested post isn't associated with this taxonomy, skip it. + if ( ! is_object_in_taxonomy( $post->post_type, $taxonomy_object->name ) ) { + continue; + } + + $allowed_taxonomies[] = $taxonomy_object->name; + } + + $terms = wp_get_object_terms( $post_id, $allowed_taxonomies ); + + if ( is_wp_error( $terms ) ) { + return new WP_Error( + 'get_terms_error', + /* translators: %1$s: Error message. */ + sprintf( esc_html__( 'Error getting terms: %1$s', 'ai' ), $terms->get_error_message() ) + ); + } + + /** + * Filters the terms returned by the get-post-terms ability. + * + * @since 0.7.0 + * + * @param array<\WP_Term> $terms The terms assigned to the post. + * @param int $post_id The post ID. + * @param array $allowed_taxonomies The allowed taxonomy names. + */ + $terms = apply_filters( 'wpai_get_post_terms', $terms, $post_id, $allowed_taxonomies ); + + return array_map( + static function ( $term ): array { + if ( $term instanceof \WP_Term ) { + return $term->to_array(); + } + + return (array) $term; + }, + $terms + ); + } + /** * Gets the post object. * diff --git a/includes/Abstracts/Abstract_Gated_Ability.php b/includes/Abstracts/Abstract_Gated_Ability.php new file mode 100644 index 000000000..65191d32d --- /dev/null +++ b/includes/Abstracts/Abstract_Gated_Ability.php @@ -0,0 +1,44 @@ + __( 'Custom Abilities', 'ai' ), + 'description' => __( 'Register the plugin\'s custom WordPress Abilities (post details & terms, settings, users, and content) for use via the Abilities API and MCP.', 'ai' ), + 'category' => Experiment_Category::ADMIN, + 'capability' => 'none', + ); + } + + /** + * {@inheritDoc} + * + * Registers every gated ability. Show_In_Abilities runs once beforehand when + * any ability depends on core objects being exposed to the Abilities API. + */ + public function register(): void { + $abilities = Gated_Abilities::get_all(); + + if ( $this->requires_core_object_exposure( $abilities ) ) { + ( new Show_In_Abilities() )->register(); + } + + foreach ( $abilities as $ability ) { + $ability->register(); + } + } + + /** + * Whether any of the given abilities needs core objects exposed to the + * Abilities API before it registers. + * + * @since x.x.x + * + * @param array<\WordPress\AI\Abstracts\Abstract_Gated_Ability> $abilities The gated abilities. + * @return bool True if any ability requires core-object exposure. + */ + private function requires_core_object_exposure( array $abilities ): bool { + foreach ( $abilities as $ability ) { + if ( $ability->requires_core_object_exposure() ) { + return true; + } + } + + return false; + } +} diff --git a/includes/Experiments/Experiments.php b/includes/Experiments/Experiments.php index be1db3b91..09eeac40b 100644 --- a/includes/Experiments/Experiments.php +++ b/includes/Experiments/Experiments.php @@ -28,6 +28,7 @@ final class Experiments { */ private const EXPERIMENT_CLASSES = array( // phpcs:ignore SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition -- This is used as an array const. \WordPress\AI\Experiments\Abilities_Explorer\Abilities_Explorer::class, + \WordPress\AI\Experiments\Custom_Abilities\Custom_Abilities::class, \WordPress\AI\Experiments\AI_Request_Logging\AI_Request_Logging::class, \WordPress\AI\Experiments\Connector_Approval\Connector_Approval::class, \WordPress\AI\Experiments\Key_Encryption\Key_Encryption::class, diff --git a/includes/Main.php b/includes/Main.php index fc2d5a080..1032601de 100644 --- a/includes/Main.php +++ b/includes/Main.php @@ -11,11 +11,6 @@ namespace WordPress\AI; -use WordPress\AI\Abilities\Content\Content as Content_Ability; -use WordPress\AI\Abilities\Settings\Settings as Settings_Ability; -use WordPress\AI\Abilities\Show_In_Abilities; -use WordPress\AI\Abilities\Users\Users as Users_Ability; -use WordPress\AI\Abilities\Utilities\Posts; use WordPress\AI\Admin\Activation; use WordPress\AI\Admin\Dashboard\Dashboard_Widgets; use WordPress\AI\Admin\Deactivation; @@ -142,16 +137,6 @@ public function initialize_features(): void { if ( is_admin() || wp_doing_cron() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { ( new Site_Health() )->init(); } - - // Register our post-related WordPress Abilities. - ( new Posts() )->register(); - - // Expose curated core objects to the Abilities API, then register the - // core abilities (overriding any core-provided copies). - ( new Show_In_Abilities() )->register(); - ( new Settings_Ability() )->init(); - ( new Users_Ability() )->init(); - ( new Content_Ability() )->init(); } catch ( \Throwable $e ) { _doing_it_wrong( __METHOD__, diff --git a/includes/helpers.php b/includes/helpers.php index e50baffcc..729843cbf 100644 --- a/includes/helpers.php +++ b/includes/helpers.php @@ -10,6 +10,7 @@ namespace WordPress\AI; use Throwable; +use WordPress\AI\Abilities\Utilities\Posts; use WordPress\AI\Experiments\Summarization\Summarization; use WordPress\AI\Services\AI_Service; use WordPress\AI\Services\Guidelines; @@ -124,56 +125,52 @@ function count_characters_excluding_spaces( string $text ): int { function get_post_context( int $post_id ): array { $context = array(); - // Get the post details using the get-post-details ability. - $details_ability = wp_get_ability( 'ai/get-post-details' ); - if ( $details_ability ) { - $details = $details_ability->execute( array( 'post_id' => $post_id ) ); + // Get the post details directly (not via the ability) so the context is + // available even when the get-post-details ability is gated off. + $details = Posts::get_post_details( $post_id ); - if ( is_array( $details ) ) { - $context = array_merge( $context, $details ); + if ( is_array( $details ) ) { + $context = array_merge( $context, $details ); - if ( isset( $context['content'] ) ) { - // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound - $context['content'] = normalize_content( (string) apply_filters( 'the_content', $context['content'] ) ); - } - - if ( isset( $context['type'] ) ) { - $context['content_type'] = $context['type']; - unset( $context['type'] ); - } + if ( isset( $context['content'] ) ) { + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound + $context['content'] = normalize_content( (string) apply_filters( 'the_content', $context['content'] ) ); + } - // Remove any empty context values. - $context = array_filter( $context ); + if ( isset( $context['type'] ) ) { + $context['content_type'] = $context['type']; + unset( $context['type'] ); } - } - // Get the post terms using the get-terms ability. - $terms_ability = wp_get_ability( 'ai/get-post-terms' ); - if ( $terms_ability ) { - $terms = $terms_ability->execute( array( 'post_id' => $post_id ) ); + // Remove any empty context values. + $context = array_filter( $context ); + } - if ( $terms && ! is_wp_error( $terms ) ) { - $grouped_terms = array(); + // Get the post terms directly (not via the ability) so the context is + // available even when the get-post-terms ability is gated off. + $terms = Posts::get_post_terms( $post_id ); - foreach ( $terms as $term ) { - $taxonomy = $term['taxonomy'] ?? ''; - $name = $term['name'] ?? ''; + if ( $terms && ! is_wp_error( $terms ) ) { + $grouped_terms = array(); - if ( '' === $taxonomy || '' === $name ) { - continue; - } + foreach ( $terms as $term ) { + $taxonomy = $term['taxonomy'] ?? ''; + $name = $term['name'] ?? ''; - $grouped_terms[ $taxonomy ][] = $name; + if ( '' === $taxonomy || '' === $name ) { + continue; } - $context = array_merge( - $context, - array_map( - static fn( array $term_names ): string => implode( ', ', $term_names ), - $grouped_terms - ) - ); + $grouped_terms[ $taxonomy ][] = $name; } + + $context = array_merge( + $context, + array_map( + static fn( array $term_names ): string => implode( ', ', $term_names ), + $grouped_terms + ) + ); } return $context; diff --git a/tests/Integration/Includes/Experiments/Content_Translation/Content_TranslationTest.php b/tests/Integration/Includes/Experiments/Content_Translation/Content_TranslationTest.php index 06d34e3c9..86ac49243 100644 --- a/tests/Integration/Includes/Experiments/Content_Translation/Content_TranslationTest.php +++ b/tests/Integration/Includes/Experiments/Content_Translation/Content_TranslationTest.php @@ -155,10 +155,19 @@ public function test_register_hooks_actions(): void { * @since x.x.x */ public function test_register_abilities_registers_content_translation_ability(): void { - $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::register' ); - - // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- External hook. - do_action( 'wp_abilities_api_init' ); + // The abilities registry persists across tests, so start from a clean + // slate to guarantee a single registration with no duplicate notice. + if ( wp_has_ability( 'ai/content-translation' ) ) { + wp_unregister_ability( 'ai/content-translation' ); + } + + global $wp_current_filter; + $wp_current_filter[] = 'wp_abilities_api_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Faking the action context to register within it. + try { + ( new Content_Translation() )->register_abilities(); + } finally { + array_pop( $wp_current_filter ); + } $ability = wp_get_ability( 'ai/content-translation' ); $this->assertNotNull( diff --git a/tests/Integration/Includes/HelpersTest.php b/tests/Integration/Includes/HelpersTest.php index 153d233fd..7632a2823 100644 --- a/tests/Integration/Includes/HelpersTest.php +++ b/tests/Integration/Includes/HelpersTest.php @@ -11,6 +11,7 @@ use ReflectionProperty; use WP_Connector_Registry; use WP_UnitTestCase; +use WordPress\AI\Abilities\Utilities\Posts; use WordPress\AI\Services\Guidelines; use WordPress\AI\Tests\Integration\Includes\Services\Guidelines_CPT_Helpers; use WordPress\AiClient\AiClient; @@ -259,6 +260,27 @@ public function setUp(): void { Guidelines::reset_cache(); $this->active_plugins = (array) get_option( 'active_plugins', array() ); + + $this->register_post_abilities(); + } + + /** + * Registers the post utility abilities within a faked init action. + * + * These abilities are gated behind the Custom Abilities experiment, so they + * are not registered by default and must be registered explicitly for the + * tests that exercise them directly. + * + * @since x.x.x + */ + private function register_post_abilities(): void { + global $wp_current_filter; + $wp_current_filter[] = 'wp_abilities_api_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Faking the action context to register within it. + try { + ( new Posts() )->register_abilities(); + } finally { + array_pop( $wp_current_filter ); + } } /** @@ -267,6 +289,13 @@ public function setUp(): void { * @since 0.1.0 */ public function tearDown(): void { + // Clean up the post utility abilities registered in setUp(). + foreach ( array( 'ai/get-post-details', 'ai/get-post-terms' ) as $ability_name ) { + if ( wp_has_ability( $ability_name ) ) { + wp_unregister_ability( $ability_name ); + } + } + $registry = WP_Connector_Registry::get_instance(); foreach ( $this->test_connector_ids as $connector_id ) { if ( null === $registry || ! $registry->is_registered( $connector_id ) ) { @@ -450,9 +479,6 @@ public function data_count_characters_excluding_spaces(): array { * @since 0.1.0 */ public function test_get_post_context_returns_empty_for_nonexistent_post() { - // Expect the incorrect usage notice when abilities are called with non-existent posts. - $this->setExpectedIncorrectUsage( 'WP_Ability::execute' ); - $context = \WordPress\AI\get_post_context( 99999 ); $this->assertIsArray( $context, 'Should return an array' );