*/ private const BULK_QUERY_ARGS = array( 'wpai_bulk_alt_text', 'wpai_attachment_ids', '_wpai_bulk_nonce' ); // phpcs:ignore SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition -- This is used as an array const. /** * Nonce action signing the bulk action redirect. * * @since 1.4.0 * * @var string */ private const BULK_NONCE_ACTION = 'wpai_bulk_alt_text'; /** * {@inheritDoc} */ public static function get_id(): string { return 'alt-text-generation'; } /** * Tracks whether the media-focused assets have already been enqueued. * * @since 0.3.0 * * @var bool */ private bool $media_assets_enqueued = false; /** * {@inheritDoc} */ protected function load_metadata(): array { return array( 'label' => __( 'Alt Text Generation', 'ai' ), 'description' => __( 'Generates accessible alternative (alt) text for images using AI vision models, following common web accessibility guidance. Requires an AI connector that includes support for vision-based image analysis models.', 'ai' ), 'category' => Experiment_Category::EDITOR, 'capability' => 'vision', 'usage' => array( 'trigger' => 'on_demand', 'runs' => __( 'When you click Generate Alt Text or run the bulk action, and automatically when you import an image made with Image Generation', 'ai' ), 'sends' => __( 'The image, plus post content when used in the editor', 'ai' ), 'requests' => __( 'One request per image', 'ai' ), ), ); } /** * {@inheritDoc} */ public function register(): void { add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_editor_assets' ) ); add_action( 'wp_enqueue_media', array( $this, 'enqueue_media_frame_assets' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'maybe_enqueue_media_library_assets' ) ); add_action( 'add_meta_boxes_attachment', array( $this, 'setup_attachment_meta_box' ) ); add_filter( 'attachment_fields_to_edit', array( $this, 'add_button_to_media_modal' ), 10, 2 ); add_filter( 'bulk_actions-upload', array( $this, 'register_bulk_action' ) ); add_filter( 'handle_bulk_actions-upload', array( $this, 'handle_bulk_action' ), 10, 3 ); add_filter( 'removable_query_args', array( $this, 'register_removable_query_args' ) ); if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) { return; } \WP_CLI::add_command( 'ai alt-text', Alt_Text_Command::class ); } /** * Registers any needed abilities. * * @since 0.3.0 */ public function register_abilities(): void { wp_register_ability( 'ai/' . self::get_id(), array( 'label' => $this->get_label(), 'description' => $this->get_description(), 'ability_class' => Alt_Text_Generation_Ability::class, ), ); } /** * Enqueues block editor assets. * * @since 0.3.0 */ public function enqueue_editor_assets(): void { Asset_Loader::enqueue_script( 'alt_text_generation', 'experiments/alt-text-generation', array( 'include_core_abilities' => true ) ); Asset_Loader::localize_script( 'alt_text_generation', 'AltTextGenerationData', array( 'enabled' => $this->is_enabled(), ) ); $this->maybe_enqueue_media_script(); $this->maybe_enqueue_media_editor_script(); } /** * Enqueues assets whenever the core media modal is registered. * * @since 0.3.0 */ public function enqueue_media_frame_assets(): void { $this->maybe_enqueue_media_script(); } /** * Conditionally enqueues assets on media-related admin screens (e.g., upload.php). * * @since 0.3.0 * * @param string $hook_suffix Current admin page hook suffix. */ public function maybe_enqueue_media_library_assets( string $hook_suffix ): void { if ( ! $this->is_enabled() ) { return; } if ( in_array( $hook_suffix, array( 'upload.php', 'media-new.php' ), true ) ) { $this->maybe_enqueue_media_script(); $this->maybe_enqueue_bulk_script(); return; } $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; if ( ! $screen || 'attachment' !== $screen->post_type ) { return; } $this->maybe_enqueue_media_script(); } /** * Shared helper to enqueue and localize the media UI script once per request. * * @since 0.3.0 */ private function maybe_enqueue_media_script(): void { if ( $this->media_assets_enqueued || ! $this->is_enabled() ) { return; } Asset_Loader::enqueue_script( 'alt_text_generation_media', 'experiments/alt-text-generation-media', array( 'include_core_abilities' => true ) ); Asset_Loader::localize_script( 'alt_text_generation_media', 'AltTextGenerationMediaData', array( 'enabled' => $this->is_enabled(), ) ); $this->media_assets_enqueued = true; } /** * Conditionally enqueues assets for the experimental Gutenberg media editor. * * Requires the Gutenberg media editor experiment or media modal experiment to be enabled. * * @since 1.0.0 */ private function maybe_enqueue_media_editor_script(): void { if ( ! is_plugin_active( 'gutenberg/gutenberg.php' ) ) { return; } $experiments = get_option( 'gutenberg-experiments' ); if ( ! isset( $experiments['gutenberg-media-editor'] ) && ! isset( $experiments['gutenberg-media-editor-modal'] ) ) { return; } Asset_Loader::enqueue_script( 'alt_text_generation_media_editor', 'experiments/alt-text-generation-media-editor' ); } /** * Sets up the attachment meta box. * * Adds a meta box to the attachment edit screen that contains * the Generate/Regenerate button. * * @since 0.3.0 * * @param \WP_Post $post The attachment post. */ public function setup_attachment_meta_box( \WP_Post $post ): void { if ( ! $this->is_enabled() || ! wp_attachment_is_image( $post ) ) { return; } add_meta_box( 'ai_alt_text_generation', __( 'Alt Text', 'ai' ), array( $this, 'render_attachment_meta_box' ), 'attachment', ); } /** * Renders the attachment meta box content. * * @since 0.3.0 * * @param \WP_Post $post The attachment post. */ public function render_attachment_meta_box( \WP_Post $post ): void { $button_text = empty( get_post_meta( $post->ID, '_wp_attachment_image_alt', true ) ) ? __( 'Generate', 'ai' ) : __( 'Regenerate', 'ai' ); printf( '
' . '' . '' . '

' . '
', absint( $post->ID ), esc_html( $button_text ) ); } /** * Adds the "Generate Alt Text" option to the Media Library bulk actions menu. * * @since 0.7.0 * * @param array $actions The existing bulk actions. * @return array The bulk actions with the generate alt text option added. */ public function register_bulk_action( array $actions ): array { if ( ! $this->is_enabled() ) { return $actions; } $actions['wpai_generate_alt_text'] = __( 'Generate Alt Text', 'ai' ); return $actions; } /** * Registers the bulk alt text trigger params as removable query args. * * The bulk action redirect carries `wpai_bulk_alt_text` and * `wpai_attachment_ids` in the URL, and the bulk script runs whenever they * are present. Listing them here lets core clean them out of the address * bar on the first paint, via the canonical URL it prints in `admin_head`, * so reloading the results page does not re-trigger the whole generation. * The sort, pagination, and view switcher links are handled by the request * URI scrub in {@see Alt_Text_Generation::maybe_enqueue_bulk_script()}. * * @since 1.3.0 * * @param list $args Query args removed from admin URLs. * @return list Args including the bulk alt text trigger params. */ public function register_removable_query_args( array $args ): array { return array_merge( $args, self::BULK_QUERY_ARGS ); } /** * Handles the "Generate Alt Text" bulk action by redirecting with selected image IDs. * * @since 0.7.0 * * @param string $redirect_url The current redirect URL. * @param string $doaction The bulk action being performed. * @param list $post_ids The list of post IDs to process. * @return string The redirect URL, possibly with bulk alt text query args appended. */ public function handle_bulk_action( string $redirect_url, string $doaction, array $post_ids ): string { if ( 'wpai_generate_alt_text' !== $doaction || ! current_user_can( 'upload_files' ) ) { return $redirect_url; } $image_ids = array_values( array_filter( $post_ids, 'wp_attachment_is_image' ) ); if ( empty( $image_ids ) ) { return $redirect_url; } return add_query_arg( array( 'wpai_bulk_alt_text' => 1, 'wpai_attachment_ids' => implode( ',', array_map( 'absint', $image_ids ) ), '_wpai_bulk_nonce' => wp_create_nonce( self::BULK_NONCE_ACTION ), ), $redirect_url ); } /** * Enqueues the bulk alt text script when a bulk action redirect is detected. * * @since 0.7.0 */ private function maybe_enqueue_bulk_script(): void { if ( ! isset( $_GET['wpai_bulk_alt_text'] ) || ! current_user_can( 'upload_files' ) ) { return; } $nonce = isset( $_GET['_wpai_bulk_nonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpai_bulk_nonce'] ) ) : ''; if ( ! wp_verify_nonce( $nonce, self::BULK_NONCE_ACTION ) ) { return; } $raw_ids = isset( $_GET['wpai_attachment_ids'] ) ? sanitize_text_field( wp_unslash( $_GET['wpai_attachment_ids'] ) ) : ''; $ids = array_values( array_unique( array_filter( array_map( 'absint', explode( ',', $raw_ids ) ) ) ) ); if ( empty( $ids ) ) { return; } // One billed model call per image, so bound the batch. $max_items = get_bulk_action_max_items( $this->get_id() ); $truncated_count = max( 0, count( $ids ) - $max_items ); $ids = array_slice( $ids, 0, $max_items ); /* * The trigger params have been read; scrub them from the request URI so * the sort header links the list table builds from it do not carry them. * Sorting links only strip `paged`, not removable query args, so this * mirrors what core does for its own one-shot params in wp-admin/upload.php. * The script receives the attachment IDs through wp_localize_script() * below and does not need them to stay in the URL. The value is only * rewritten, not output, so no sanitization applies. */ if ( isset( $_SERVER['REQUEST_URI'] ) ) { $_SERVER['REQUEST_URI'] = remove_query_arg( self::BULK_QUERY_ARGS, (string) $_SERVER['REQUEST_URI'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized } Asset_Loader::enqueue_script( 'alt_text_generation_bulk', 'experiments/alt-text-generation-bulk', array( 'include_core_abilities' => true ) ); Asset_Loader::localize_script( 'alt_text_generation_bulk', 'AltTextGenerationBulkData', array( 'attachmentIds' => $ids, 'truncatedCount' => $truncated_count, ) ); } /** * Adds a button to the media modal to generate alt text. * * @since 0.3.0 * * @param array $fields The attachment fields. * @param \WP_Post|null $post The attachment post. * @return array The attachment fields with the button added. */ public function add_button_to_media_modal( array $fields, ?\WP_Post $post ): array { if ( ! $this->is_enabled() || null === $post || ! wp_attachment_is_image( $post ) ) { return $fields; } $button_text = empty( get_post_meta( $post->ID, '_wp_attachment_image_alt', true ) ) ? __( 'Generate', 'ai' ) : __( 'Regenerate', 'ai' ); $fields['ai_alt_text'] = array( 'label' => __( 'Alt Text', 'ai' ), 'input' => 'html', 'show_in_edit' => false, 'html' => sprintf( '
' . '' . '' . '

' . '
', absint( $post->ID ), esc_html( $button_text ) ), ); return $fields; } }