diff --git a/admin/class-enqueue-admin.php b/admin/class-enqueue-admin.php index 137374432..9bb0c26bf 100644 --- a/admin/class-enqueue-admin.php +++ b/admin/class-enqueue-admin.php @@ -212,6 +212,7 @@ public static function maybe_enqueue_sidebar_script() { 'readabilityHelpUrl' => esc_url_raw( edac_link_wrapper( 'https://a11ychecker.com/help3265', 'wordpress-general', 'content-analysis-sidebar', false ) ), 'dismissReasons' => IgnoreUI::get_reasons(), 'simplifiedSummaryPrompt' => get_option( 'edac_simplified_summary_prompt', 'none' ), + 'aiAltAvailable' => \EqualizeDigital\AccessibilityChecker\AI\AltTextGenerator::is_available(), ] ); diff --git a/includes/classes/AI/AltTextGenerator.php b/includes/classes/AI/AltTextGenerator.php new file mode 100644 index 000000000..75950f90e --- /dev/null +++ b/includes/classes/AI/AltTextGenerator.php @@ -0,0 +1,465 @@ +with_file( $data_uri )->generate_text() + * with_file() expects a base64 data URI, not a URL. + * generate_text() returns string|WP_Error directly. + * + * AI Services plugin (Felix Arntz / felixarntz/ai-services): + * ai_services()->get_available_service()->get_model()->generate_text( $content ) + * Returns a Candidates object; text extracted via ->get(0)->get_content()->...->get_text(). + */ +class AltTextGenerator { + + /** + * Rule slugs that support AI-assisted alt text generation. + */ + const SUPPORTED_RULES = [ 'img_alt_missing', 'img_alt_empty', 'img_alt_invalid' ]; + + /** + * Number of alt text suggestions to request by default. + */ + const DEFAULT_NUM_SUGGESTIONS = 3; + + /** + * Check whether any supported AI integration is available. + * + * @return bool + */ + public static function is_available(): bool { + return function_exists( 'wp_ai_client_prompt' ) || function_exists( 'ai_services' ); + } + + /** + * Generate alt text suggestions for a media attachment. + * + * @param int $attachment_id WordPress attachment post ID. + * @param int $num_suggestions Number of suggestions to generate (1–5). + * @return array|\WP_Error Array of suggestion objects or WP_Error on failure. + */ + public static function generate( int $attachment_id, int $num_suggestions = self::DEFAULT_NUM_SUGGESTIONS ) { + if ( ! self::is_available() ) { + return new \WP_Error( + 'no_ai_service', + __( 'No AI service is available. Please configure the WordPress AI connector under Settings > Connectors, or install and configure the AI Services plugin.', 'accessibility-checker' ) + ); + } + + if ( 'attachment' !== get_post_type( $attachment_id ) ) { + return new \WP_Error( 'invalid_attachment', __( 'The provided ID is not a valid media attachment.', 'accessibility-checker' ) ); + } + + $num_suggestions = max( 1, min( 5, $num_suggestions ) ); + $prompt = self::build_prompt( $num_suggestions ); + + if ( function_exists( 'wp_ai_client_prompt' ) ) { + $data_uri = self::get_image_data_uri( $attachment_id ); + if ( is_wp_error( $data_uri ) ) { + return $data_uri; + } + return self::generate_with_wp_ai_client( $data_uri, $prompt ); + } + + $image_url = wp_get_attachment_image_url( $attachment_id, 'full' ); + if ( ! $image_url ) { + return new \WP_Error( 'no_image_url', __( 'Could not retrieve an image URL for this attachment.', 'accessibility-checker' ) ); + } + + return self::generate_with_ai_services( $image_url, $prompt ); + } + + /** + * Get the image as a base64 data URI. + * + * Uses the 'large' WordPress image size (typically ≤1024 px) to keep the + * request payload small enough to avoid API timeouts, falling back to + * 'full' and then a direct download of whichever URL is available. + * + * @param int $attachment_id Attachment post ID. + * @return string|\WP_Error Data URI string or WP_Error on failure. + */ + private static function get_image_data_uri( int $attachment_id ) { + // Try each size from smallest-useful to largest, stopping at the first + // local file we can actually read. + foreach ( [ 'large', 'medium_large', 'medium', 'full' ] as $size ) { + $local = self::local_path_for_size( $attachment_id, $size ); + if ( $local ) { + $data_uri = self::file_to_data_uri( $local ); + if ( $data_uri ) { + return $data_uri; + } + } + } + + // No local file worked — download whichever URL the image has. + // Prefer 'large' to keep the payload small. + $image_url = wp_get_attachment_image_url( $attachment_id, 'large' ) + ?: wp_get_attachment_image_url( $attachment_id, 'full' ); + + if ( ! $image_url ) { + return new \WP_Error( 'no_image_url', __( 'Could not retrieve an image URL for this attachment.', 'accessibility-checker' ) ); + } + + if ( ! function_exists( 'download_url' ) ) { + require_once ABSPATH . 'wp-admin/includes/file.php'; + } + + $temp_file = download_url( $image_url ); + if ( is_wp_error( $temp_file ) ) { + return $temp_file; + } + + $data_uri = self::file_to_data_uri( $temp_file ); + wp_delete_file( $temp_file ); + + if ( ! $data_uri ) { + return new \WP_Error( 'file_read_error', __( 'Could not read the image file.', 'accessibility-checker' ) ); + } + + return $data_uri; + } + + /** + * Return the absolute filesystem path for a specific WordPress image size, + * or null if it cannot be resolved to an existing file. + * + * @param int $attachment_id Attachment post ID. + * @param string $size WordPress image size name. + * @return string|null + */ + private static function local_path_for_size( int $attachment_id, string $size ): ?string { + if ( 'full' === $size ) { + $path = get_attached_file( $attachment_id ); + return ( $path && file_exists( $path ) ) ? $path : null; + } + + $meta = wp_get_attachment_metadata( $attachment_id ); + if ( empty( $meta['sizes'][ $size ]['file'] ) ) { + return null; + } + + $upload_dir = wp_upload_dir(); + $base_dir = isset( $meta['file'] ) + ? trailingslashit( $upload_dir['basedir'] ) . trailingslashit( dirname( $meta['file'] ) ) + : trailingslashit( $upload_dir['path'] ); + + $path = $base_dir . $meta['sizes'][ $size ]['file']; + return file_exists( $path ) ? $path : null; + } + + /** + * Convert a local file path to a base64 data URI. + * + * @param string $file_path Absolute path to the file. + * @return string|null Data URI or null on failure. + */ + private static function file_to_data_uri( string $file_path ): ?string { + $mime_type = wp_check_filetype( $file_path )['type'] ?? null; + if ( ! $mime_type ) { + return null; + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown + $contents = file_get_contents( $file_path ); + if ( false === $contents ) { + return null; + } + + return 'data:' . $mime_type . ';base64,' . base64_encode( $contents ); + } + + /** + * Build the structured prompt for alt text generation. + * + * @param int $num Number of suggestions to request. + * @return string + */ + private static function build_prompt( int $num ): string { + return sprintf( + 'Analyze this image and generate %1$d distinct, accessibility-focused alt text suggestions. + +Each suggestion must: +- Be under 125 characters (optimal for screen readers) +- Describe exactly what is visually visible in the image +- Focus on a different visual element or aspect across the %1$d suggestions (e.g., main subject, setting, action, or composition) +- Avoid starting with "Image of", "Photo of", "Picture of", or similar redundant phrases +- Use plain language, present tense, and active voice + +Return ONLY a valid JSON array with exactly %1$d objects. Each object must have these keys: +- "alt": the alt text string (required, under 125 characters) +- "focus": a short noun phrase identifying the focal element of this suggestion (required) +- "explanation": one sentence explaining what aspect of the image this alt text emphasizes (required) + +Example format: +[{"alt": "Worker harvesting ripe coffee cherries by hand", "focus": "harvester", "explanation": "Emphasizes the human labor and manual process."}] + +Return only the JSON array with no markdown fencing or other text.', + $num + ); + } + + /** + * Generate using the WordPress AI connector (wp_ai_client_prompt). + * + * The builder API is: + * wp_ai_client_prompt( $prompt )->with_file( $data_uri )->generate_text() + * + * with_file() requires a base64 data URI, not a plain URL. + * generate_text() returns string|WP_Error directly. + * + * @param string $data_uri Base64 data URI of the image. + * @param string $prompt The generation prompt. + * @return array|\WP_Error + */ + private static function generate_with_wp_ai_client( string $data_uri, string $prompt ) { + try { + $result = wp_ai_client_prompt( $prompt ) + ->with_file( $data_uri ) + ->generate_text(); + + if ( is_wp_error( $result ) ) { + return $result; + } + + return self::parse_suggestions( (string) $result ); + + } catch ( \Throwable $e ) { + return new \WP_Error( 'wp_ai_client_error', $e->getMessage() ); + } + } + + /** + * Generate using the AI Services plugin (Felix Arntz / felixarntz/ai-services). + * + * @param string $image_url Full URL of the image. + * @param string $prompt The generation prompt. + * @return array|\WP_Error + */ + private static function generate_with_ai_services( string $image_url, string $prompt ) { + try { + $ai = ai_services(); + + // Request a service that supports both image understanding and text generation. + $service = $ai->get_available_service( + [ + 'capabilities' => [ 'MULTIMODAL_INPUT', 'TEXT_GENERATION' ], + ] + ); + + if ( is_wp_error( $service ) ) { + // Fallback: any text-generation capable service. + $service = $ai->get_available_service( + [ + 'capabilities' => [ 'TEXT_GENERATION' ], + ] + ); + } + + if ( is_wp_error( $service ) ) { + return new \WP_Error( + 'no_configured_service', + __( 'No AI service is configured. Please add an API key under Settings > AI Services.', 'accessibility-checker' ) + ); + } + + $model = $service->get_model( + [ + 'feature' => 'accessibility-checker-alt-text', + 'capabilities' => [ 'MULTIMODAL_INPUT', 'TEXT_GENERATION' ], + ] + ); + + if ( is_wp_error( $model ) ) { + return $model; + } + + // Build content: image URL part + text prompt part. + $content_args = [ + [ + 'role' => 'user', + 'parts' => [ + [ + 'type' => 'image_url', + 'url' => $image_url, + ], + [ + 'type' => 'text', + 'text' => $prompt, + ], + ], + ], + ]; + + $candidates = $model->generate_text( $content_args ); + + if ( is_wp_error( $candidates ) ) { + return $candidates; + } + + $text = self::extract_text_from_candidates( $candidates ); + if ( is_wp_error( $text ) ) { + return $text; + } + + return self::parse_suggestions( $text ); + + } catch ( \Throwable $e ) { + return new \WP_Error( 'ai_services_error', $e->getMessage() ); + } + } + + /** + * Extract a generated text string from an ai-services Candidates object. + * + * Candidates object shape: + * ->get(0)->get_content()->get_parts()->get(0)->get_text() + * + * @param mixed $candidates Candidates object from ai-services generate_text(). + * @return string|\WP_Error + */ + private static function extract_text_from_candidates( $candidates ) { + if ( is_string( $candidates ) ) { + return $candidates; + } + + if ( ! is_object( $candidates ) ) { + return new \WP_Error( 'unexpected_response', __( 'Unexpected AI response format. Please try again.', 'accessibility-checker' ) ); + } + + try { + // ai-services Candidates: indexed access via ->get(). + if ( method_exists( $candidates, 'get' ) ) { + $candidate = $candidates->get( 0 ); + if ( $candidate ) { + $text = self::text_from_candidate( $candidate ); + if ( is_string( $text ) && '' !== $text ) { + return $text; + } + } + } + + // Convenience / shortcut methods. + foreach ( [ 'get_first_candidate_text', 'get_text', 'getText' ] as $method ) { + if ( method_exists( $candidates, $method ) ) { + $text = $candidates->$method(); + if ( is_string( $text ) && '' !== $text ) { + return $text; + } + } + } + } catch ( \Throwable $e ) { + return new \WP_Error( 'text_extraction_error', $e->getMessage() ); + } + + return new \WP_Error( + 'unexpected_response', + sprintf( + /* translators: %s: PHP class name of the unexpected response object */ + __( 'Unexpected AI response type (%s). Please report this to the plugin author.', 'accessibility-checker' ), + get_class( $candidates ) + ) + ); + } + + /** + * Extract text from a single candidate object. + * + * @param mixed $candidate Candidate object. + * @return string|null Text string or null if not extractable. + */ + private static function text_from_candidate( $candidate ): ?string { + if ( ! is_object( $candidate ) ) { + return null; + } + + if ( method_exists( $candidate, 'get_content' ) ) { + $content = $candidate->get_content(); + if ( $content && method_exists( $content, 'get_parts' ) ) { + $parts = $content->get_parts(); + $first_part = null; + + if ( is_array( $parts ) ) { + $first_part = reset( $parts ) ?: null; + } elseif ( is_object( $parts ) && method_exists( $parts, 'get' ) ) { + $first_part = $parts->get( 0 ); + } + + if ( $first_part && method_exists( $first_part, 'get_text' ) ) { + $text = $first_part->get_text(); + if ( is_string( $text ) ) { + return $text; + } + } + } + } + + foreach ( [ 'get_text', 'getText' ] as $method ) { + if ( method_exists( $candidate, $method ) ) { + $text = $candidate->$method(); + if ( is_string( $text ) ) { + return $text; + } + } + } + + return null; + } + + /** + * Parse JSON suggestion objects from the AI response text. + * + * @param string $text Raw text from the AI response. + * @return array|\WP_Error + */ + private static function parse_suggestions( string $text ) { + // Strip markdown code fences if the AI wrapped the JSON. + $text = preg_replace( '/^```(?:json)?\s*/m', '', $text ); + $text = preg_replace( '/\s*```\s*$/m', '', $text ); + $text = trim( $text ); + + $data = json_decode( $text, true, 3 ); + + if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $data ) ) { + return new \WP_Error( + 'parse_error', + __( 'Could not parse the AI response. Please try again.', 'accessibility-checker' ) + ); + } + + $suggestions = []; + foreach ( $data as $item ) { + if ( ! is_array( $item ) || empty( $item['alt'] ) ) { + continue; + } + $suggestions[] = [ + 'alt' => sanitize_text_field( $item['alt'] ), + 'focus' => sanitize_text_field( $item['focus'] ?? '' ), + 'explanation' => sanitize_text_field( $item['explanation'] ?? '' ), + ]; + } + + if ( empty( $suggestions ) ) { + return new \WP_Error( + 'no_suggestions', + __( 'No valid alt text suggestions were generated. Please try again.', 'accessibility-checker' ) + ); + } + + return $suggestions; + } +} diff --git a/includes/classes/AI/SimplifiedSummaryGenerator.php b/includes/classes/AI/SimplifiedSummaryGenerator.php new file mode 100644 index 000000000..768031a0f --- /dev/null +++ b/includes/classes/AI/SimplifiedSummaryGenerator.php @@ -0,0 +1,262 @@ +generate_text() → string|WP_Error + * + * AI Services plugin: + * ai_services()->get_available_service()->get_model()->generate_text() + */ +class SimplifiedSummaryGenerator { + + /** + * Maximum number of characters of post content to send to the AI. + * Keeps token usage and latency reasonable for long posts. + */ + const MAX_CONTENT_LENGTH = 8000; + + /** + * Check whether any supported AI integration is available. + * + * @return bool + */ + public static function is_available(): bool { + return AltTextGenerator::is_available(); + } + + /** + * Generate a simplified summary for a post. + * + * @param int $post_id WordPress post ID. + * @return string|\WP_Error The generated summary text or WP_Error on failure. + */ + public static function generate( int $post_id ) { + if ( ! self::is_available() ) { + return new \WP_Error( + 'no_ai_service', + __( 'No AI service is available. Please configure the WordPress AI connector under Settings > Connectors, or install and configure the AI Services plugin.', 'accessibility-checker' ) + ); + } + + $post = get_post( $post_id ); + if ( ! $post ) { + return new \WP_Error( 'invalid_post', __( 'The provided ID is not a valid post.', 'accessibility-checker' ) ); + } + + $content = self::get_post_text( $post ); + if ( '' === $content ) { + return new \WP_Error( 'no_content', __( 'This post does not have enough content to summarize.', 'accessibility-checker' ) ); + } + + $prompt = self::build_prompt( $content ); + + if ( function_exists( 'wp_ai_client_prompt' ) ) { + return self::generate_with_wp_ai_client( $prompt ); + } + + return self::generate_with_ai_services( $prompt ); + } + + /** + * Extract plain text from post content, applying the same filters used + * by the readability REST handler so the AI sees what the reader sees. + * + * @param \WP_Post $post Post object. + * @return string Plain text content, truncated to MAX_CONTENT_LENGTH. + */ + private static function get_post_text( \WP_Post $post ): string { + $content = $post->post_content; + + // Apply content filters (shortcodes, blocks, etc.). + $content = apply_filters( 'the_content', $content ); + + // Allow third-party plugins to modify what we send to the AI. + $content = apply_filters( 'edac_filter_readability_content', $content, $post->ID ); + + // Strip all HTML tags, matching the readability grade calculation. + $content = wp_filter_nohtml_kses( $content ); + $content = str_replace( ']]>', ']]>', $content ); + $content = trim( $content ); + + if ( strlen( $content ) > self::MAX_CONTENT_LENGTH ) { + $content = substr( $content, 0, self::MAX_CONTENT_LENGTH ); + } + + return $content; + } + + /** + * Build the AI prompt. + * + * @param string $content Plain-text post content. + * @return string + */ + private static function build_prompt( string $content ): string { + return sprintf( + 'Write a simplified summary of the following content. The summary must: +- Be written at or below an 8th-grade reading level (Flesch-Kincaid Grade Level 8 or lower) +- Use short sentences (15 words or fewer on average) +- Use common, everyday words — avoid jargon, technical terms, and complex vocabulary +- Cover the main idea and key points of the content +- Be between 2 and 5 sentences long +- Be written in plain prose, not as a list or with headings +- Be suitable for people with cognitive or reading disabilities + +Return only the summary text with no preamble, explanation, or formatting. + +Content to summarize: +%s', + $content + ); + } + + /** + * Generate using the WordPress AI connector (wp_ai_client_prompt). + * + * Text-only prompt — no image attachment needed. + * generate_text() returns string|WP_Error directly. + * + * @param string $prompt The generation prompt. + * @return string|\WP_Error + */ + private static function generate_with_wp_ai_client( string $prompt ) { + try { + $result = wp_ai_client_prompt( $prompt )->generate_text(); + + if ( is_wp_error( $result ) ) { + return $result; + } + + return sanitize_textarea_field( trim( (string) $result ) ); + + } catch ( \Throwable $e ) { + return new \WP_Error( 'wp_ai_client_error', $e->getMessage() ); + } + } + + /** + * Generate using the AI Services plugin (Felix Arntz / felixarntz/ai-services). + * + * @param string $prompt The generation prompt. + * @return string|\WP_Error + */ + private static function generate_with_ai_services( string $prompt ) { + try { + $ai = ai_services(); + $service = $ai->get_available_service( [ 'capabilities' => [ 'TEXT_GENERATION' ] ] ); + + if ( is_wp_error( $service ) ) { + return new \WP_Error( + 'no_configured_service', + __( 'No AI service is configured. Please add an API key under Settings > AI Services.', 'accessibility-checker' ) + ); + } + + $model = $service->get_model( + [ + 'feature' => 'accessibility-checker-simplified-summary', + 'capabilities' => [ 'TEXT_GENERATION' ], + ] + ); + + if ( is_wp_error( $model ) ) { + return $model; + } + + $candidates = $model->generate_text( + [ + [ + 'role' => 'user', + 'parts' => [ [ 'type' => 'text', 'text' => $prompt ] ], + ], + ] + ); + + if ( is_wp_error( $candidates ) ) { + return $candidates; + } + + $text = self::extract_text( $candidates ); + if ( is_wp_error( $text ) ) { + return $text; + } + + return sanitize_textarea_field( trim( $text ) ); + + } catch ( \Throwable $e ) { + return new \WP_Error( 'ai_services_error', $e->getMessage() ); + } + } + + /** + * Extract text from an ai-services Candidates object. + * + * @param mixed $candidates Candidates object. + * @return string|\WP_Error + */ + private static function extract_text( $candidates ) { + if ( is_string( $candidates ) ) { + return $candidates; + } + + if ( ! is_object( $candidates ) ) { + return new \WP_Error( 'unexpected_response', __( 'Unexpected AI response format. Please try again.', 'accessibility-checker' ) ); + } + + try { + if ( method_exists( $candidates, 'get' ) ) { + $candidate = $candidates->get( 0 ); + if ( $candidate && method_exists( $candidate, 'get_content' ) ) { + $content = $candidate->get_content(); + if ( $content && method_exists( $content, 'get_parts' ) ) { + $parts = $content->get_parts(); + $first_part = is_array( $parts ) ? reset( $parts ) : ( method_exists( $parts, 'get' ) ? $parts->get( 0 ) : null ); + if ( $first_part && method_exists( $first_part, 'get_text' ) ) { + $text = $first_part->get_text(); + if ( is_string( $text ) && '' !== $text ) { + return $text; + } + } + } + } + } + + foreach ( [ 'get_first_candidate_text', 'get_text', 'getText' ] as $method ) { + if ( method_exists( $candidates, $method ) ) { + $text = $candidates->$method(); + if ( is_string( $text ) && '' !== $text ) { + return $text; + } + } + } + } catch ( \Throwable $e ) { + return new \WP_Error( 'text_extraction_error', $e->getMessage() ); + } + + return new \WP_Error( + 'unexpected_response', + sprintf( + /* translators: %s: PHP class name */ + __( 'Unexpected AI response type (%s). Please report this to the plugin author.', 'accessibility-checker' ), + get_class( $candidates ) + ) + ); + } +} diff --git a/includes/classes/class-rest-api.php b/includes/classes/class-rest-api.php index 3af861d19..b06567888 100644 --- a/includes/classes/class-rest-api.php +++ b/includes/classes/class-rest-api.php @@ -356,6 +356,72 @@ function () use ( $ns, $version ) { ); } ); + + // AI simplified summary generation endpoint. + add_action( + 'rest_api_init', + function () use ( $ns, $version ) { + register_rest_route( + $ns . $version, + '/generate-simplified-summary', + [ + 'methods' => 'POST', + 'callback' => [ $this, 'generate_simplified_summary' ], + 'args' => [ + 'post_id' => [ + 'required' => true, + 'type' => 'integer', + 'validate_callback' => function ( $param ) { + return is_numeric( $param ) && $param > 0; + }, + 'sanitize_callback' => 'absint', + ], + ], + 'permission_callback' => function ( \WP_REST_Request $request ) { + $post_id = (int) $request->get_param( 'post_id' ); + return current_user_can( 'edit_post', $post_id ); + }, + ] + ); + } + ); + + // AI alt text generation endpoint. + add_action( + 'rest_api_init', + function () use ( $ns, $version ) { + register_rest_route( + $ns . $version, + '/generate-alt-text', + [ + 'methods' => 'POST', + 'callback' => [ $this, 'generate_alt_text' ], + 'args' => [ + 'attachment_id' => [ + 'required' => true, + 'type' => 'integer', + 'validate_callback' => function ( $param ) { + return is_numeric( $param ) && $param > 0; + }, + 'sanitize_callback' => 'absint', + ], + 'num_suggestions' => [ + 'required' => false, + 'type' => 'integer', + 'default' => 3, + 'minimum' => 1, + 'maximum' => 5, + 'sanitize_callback' => 'absint', + ], + ], + 'permission_callback' => function ( \WP_REST_Request $request ) { + $attachment_id = (int) $request->get_param( 'attachment_id' ); + return current_user_can( 'edit_post', $attachment_id ); + }, + ] + ); + } + ); } /** @@ -1344,4 +1410,67 @@ public function dismiss_issue( $request ) { 200 ); } + + /** + * REST handler that generates an AI simplified summary for a post. + * + * @param \WP_REST_Request $request The REST request. + * @return \WP_REST_Response + */ + public function generate_simplified_summary( \WP_REST_Request $request ): \WP_REST_Response { + $post_id = (int) $request->get_param( 'post_id' ); + + $result = \EqualizeDigital\AccessibilityChecker\AI\SimplifiedSummaryGenerator::generate( $post_id ); + + if ( is_wp_error( $result ) ) { + return new \WP_REST_Response( + [ + 'success' => false, + 'message' => $result->get_error_message(), + 'code' => $result->get_error_code(), + ], + 400 + ); + } + + return new \WP_REST_Response( + [ + 'success' => true, + 'summary' => $result, + ], + 200 + ); + } + + /** + * REST handler that generates AI alt text suggestions for a media attachment. + * + * @param \WP_REST_Request $request The REST request. + * @return \WP_REST_Response + */ + public function generate_alt_text( \WP_REST_Request $request ): \WP_REST_Response { + $attachment_id = (int) $request->get_param( 'attachment_id' ); + $num_suggestions = (int) $request->get_param( 'num_suggestions' ); + + $result = \EqualizeDigital\AccessibilityChecker\AI\AltTextGenerator::generate( $attachment_id, $num_suggestions ); + + if ( is_wp_error( $result ) ) { + return new \WP_REST_Response( + [ + 'success' => false, + 'message' => $result->get_error_message(), + 'code' => $result->get_error_code(), + ], + 400 + ); + } + + return new \WP_REST_Response( + [ + 'success' => true, + 'suggestions' => $result, + ], + 200 + ); + } } diff --git a/package-lock.json b/package-lock.json index 6338bb589..30db7cd45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "accessibility-checker", - "version": "1.42.1", + "version": "1.43.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "accessibility-checker", - "version": "1.42.1", + "version": "1.43.0", "hasInstallScript": true, "license": "GPL-2.0+", "devDependencies": { diff --git a/src/issueModal/components/AltTextPanel.js b/src/issueModal/components/AltTextPanel.js new file mode 100644 index 000000000..f6d4a23ae --- /dev/null +++ b/src/issueModal/components/AltTextPanel.js @@ -0,0 +1,305 @@ +import { __, sprintf } from '@wordpress/i18n'; +import { Panel, PanelBody, Button, Spinner, Notice } from '@wordpress/components'; +import { useState, useCallback } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; +import { setPendingRescan } from '../index'; + +const ALT_RULES = [ 'img_alt_missing', 'img_alt_empty', 'img_alt_invalid' ]; +const MAX_RECOMMENDED_LENGTH = 125; + +/** + * Extract the WordPress attachment ID from an img tag's class list. + * + * @param {string} markup HTML markup from the issue object. + * @return {number|null} Attachment ID or null if not found. + */ +const extractAttachmentId = ( markup ) => { + if ( ! markup ) { + return null; + } + const match = markup.match( /wp-image-(\d+)/ ); + return match ? parseInt( match[ 1 ], 10 ) : null; +}; + +/** + * Single suggestion card component. + * + * @param {Object} props - Component props. + * @param {Object} props.suggestion - Suggestion data (alt, focus, explanation). + * @param {number} props.index - Index of this suggestion. + * @param {boolean} props.isApplied - Whether this suggestion has been applied. + * @param {boolean} props.isApplying - Whether this suggestion is currently being applied. + * @param {boolean} props.disabled - Whether buttons are disabled. + * @param {Function} props.onApply - Apply handler. + */ +const SuggestionCard = ( { suggestion, index, isApplied, isApplying, disabled, onApply } ) => { + const charCount = suggestion.alt.length; + const isTooLong = charCount > MAX_RECOMMENDED_LENGTH; + + let buttonLabel = __( 'Apply', 'accessibility-checker' ); + if ( isApplied ) { + buttonLabel = __( 'Applied ✓', 'accessibility-checker' ); + } else if ( isApplying ) { + buttonLabel = __( 'Applying…', 'accessibility-checker' ); + } + + return ( +
+

+ “{ suggestion.alt }” +

+ +

+ + { charCount }{ ' ' }{ __( 'chars', 'accessibility-checker' ) } + { isTooLong && ( + + ) } + + + { suggestion.focus && ( + + { ' — ' }{ __( 'Focus:', 'accessibility-checker' ) }{ ' ' } + { suggestion.focus } + + ) } +

+ + { suggestion.explanation && ( +

+ { suggestion.explanation } +

+ ) } + + +
+ ); +}; + +/** + * AltTextPanel Component + * + * Renders inside the IssueDetailsModal for image alt text issues, letting + * users generate AI-powered alt text suggestions and apply them to the attachment. + * + * @param {Object} props - Component props. + * @param {Object} props.rule - Rule data including slug. + * @param {Object} props.issue - Issue data including HTML object. + * @param {boolean} props.isOpen - Whether the panel body is open. + * @param {Function} props.onToggle - Panel toggle handler. + */ +const AltTextPanel = ( { rule, issue, isOpen, onToggle } ) => { + const [ isGenerating, setIsGenerating ] = useState( false ); + const [ suggestions, setSuggestions ] = useState( null ); + const [ error, setError ] = useState( null ); + const [ applyingIndex, setApplyingIndex ] = useState( null ); + const [ appliedIndex, setAppliedIndex ] = useState( null ); + + // Only show for image alt rules. + if ( ! rule?.slug || ! ALT_RULES.includes( rule.slug ) ) { + return null; + } + + // Only show when the WordPress AI connector or AI Services plugin is configured. + if ( ! window.edac_sidebar_app?.aiAltAvailable ) { + return null; + } + + const attachmentId = extractAttachmentId( issue?.object ); + if ( ! attachmentId ) { + return null; + } + + const handleGenerate = useCallback( async () => { + setIsGenerating( true ); + setError( null ); + setSuggestions( null ); + setAppliedIndex( null ); + + try { + const response = await apiFetch( { + path: '/accessibility-checker/v1/generate-alt-text', + method: 'POST', + data: { attachment_id: attachmentId, num_suggestions: 3 }, + } ); + + if ( response.success ) { + setSuggestions( response.suggestions ); + } else { + setError( response.message || __( 'Failed to generate alt text suggestions.', 'accessibility-checker' ) ); + } + } catch ( err ) { + setError( err?.message || __( 'An error occurred while contacting the AI service. Please try again.', 'accessibility-checker' ) ); + } finally { + setIsGenerating( false ); + } + }, [ attachmentId ] ); + + const handleApply = useCallback( async ( index, altText ) => { + setApplyingIndex( index ); + setError( null ); + + try { + // Save alt text to the media attachment record. + await apiFetch( { + path: `/wp/v2/media/${ attachmentId }`, + method: 'POST', + data: { alt_text: altText }, + } ); + + // Also update any matching Gutenberg image blocks so the saved markup + // reflects the new alt text immediately — without this the rendered + // DOM would still carry the old value and a rescan would still flag + // the issue (blocks store their own alt attribute in post content). + if ( window.wp?.data ) { + const blockEditorSelect = window.wp.data.select( 'core/block-editor' ); + const blockEditorDispatch = window.wp.data.dispatch( 'core/block-editor' ); + + if ( blockEditorSelect && blockEditorDispatch ) { + const findImageBlock = ( blocks ) => { + for ( const block of blocks ) { + if ( block.name === 'core/image' && block.attributes?.id === attachmentId ) { + return block; + } + if ( block.innerBlocks?.length ) { + const found = findImageBlock( block.innerBlocks ); + if ( found ) { + return found; + } + } + } + return null; + }; + + const imageBlock = findImageBlock( blockEditorSelect.getBlocks() ); + if ( imageBlock ) { + blockEditorDispatch.updateBlockAttributes( imageBlock.clientId, { alt: altText } ); + } + } + } + + setAppliedIndex( index ); + // Queue a rescan so the issue status updates after the modal closes. + setPendingRescan( true ); + } catch ( err ) { + setError( err?.message || __( 'Failed to save the alt text. Please try again.', 'accessibility-checker' ) ); + } finally { + setApplyingIndex( null ); + } + }, [ attachmentId ] ); + + return ( +
+ + +
+ { ! suggestions && ! isGenerating && ( +

+ { __( 'Use your connected AI service to generate accessible alt text suggestions for this image.', 'accessibility-checker' ) } +

+ ) } + + { error && ( + setError( null ) } + > + { error } + + ) } + + { appliedIndex !== null && ( + + { __( 'Alt text applied. This page will be rescanned when you close this dialog.', 'accessibility-checker' ) } + + ) } + + { ! suggestions && ( + + ) } + + { suggestions && ( + <> +
+ { suggestions.map( ( suggestion, index ) => ( +
+ +
+ ) ) } +
+ + { appliedIndex === null && ( + + ) } + + ) } +
+
+
+
+ ); +}; + +export default AltTextPanel; diff --git a/src/issueModal/components/IssueDetailsModal.js b/src/issueModal/components/IssueDetailsModal.js index 531b42824..49f0d4e55 100644 --- a/src/issueModal/components/IssueDetailsModal.js +++ b/src/issueModal/components/IssueDetailsModal.js @@ -13,6 +13,7 @@ import { store as editorStore } from '@wordpress/editor'; import IssueImage, { extractImageUrls } from './IssueImage'; import FixPanel from './FixPanel'; import DismissPanel from './DismissPanel'; +import AltTextPanel from './AltTextPanel'; import Badge from '../../sidebar/components/Badge'; import ExternalLinkIcon from '../../sidebar/components/ExternalLinkIcon'; import { getSeverityLabel } from '../../sidebar/utils/severityHelpers'; @@ -118,6 +119,7 @@ export const IssueDetailsModal = ( { issue, rule, onClose, isOpen, focusSection, const initializedIssueId = useRef( null ); const [ isDismissPanelOpen, setIsDismissPanelOpen ] = useState( false ); const [ isFixPanelOpen, setIsFixPanelOpen ] = useState( false ); + const [ isAltPanelOpen, setIsAltPanelOpen ] = useState( false ); // Initialize state when modal opens with a NEW issue useEffect( () => { @@ -457,6 +459,16 @@ export const IssueDetailsModal = ( { issue, rule, onClose, isOpen, focusSection, /> ) } + { /* AI Alt Text Panel - Only shows for image alt rules when AI is configured */ } + { isOpen && ( + setIsAltPanelOpen( ! isAltPanelOpen ) } + /> + ) } + { /* Dismiss Issue Panel */ } { const postId = useSelect( ( select ) => select( 'core/editor' ).getCurrentPostId(), [] ); const { settingsUrl, readabilityHelpUrl } = window?.edac_sidebar_app || {}; const [ isSaving, setIsSaving ] = useState( false ); + const [ isGenerating, setIsGenerating ] = useState( false ); const [ summaryText, setSummaryText ] = useState( '' ); const [ summaryGrade, setSummaryGrade ] = useState( 0 ); const [ summaryGradeFailed, setSummaryGradeFailed ] = useState( false ); const [ notice, setNotice ] = useState( null ); + const aiAvailable = Boolean( window?.edac_sidebar_app?.aiAltAvailable ); // Panel ID for state persistence const panelId = 'readability-analysis'; @@ -133,6 +135,38 @@ const ReadabilityAnalysis = () => { } }; + // Handle AI summary generation + const handleGenerateSummary = async () => { + setIsGenerating( true ); + setNotice( null ); + try { + const response = await apiFetch( { + path: '/accessibility-checker/v1/generate-simplified-summary', + method: 'POST', + data: { post_id: postId }, + } ); + if ( response.success ) { + setSummaryText( response.summary ); + setNotice( { + type: 'info', + message: __( 'AI summary generated. Review it and click Save Summary when ready.', 'accessibility-checker' ), + } ); + } else { + setNotice( { + type: 'error', + message: response.message || __( 'Failed to generate a summary. Please try again.', 'accessibility-checker' ), + } ); + } + } catch ( err ) { + setNotice( { + type: 'error', + message: err?.message || __( 'An error occurred while contacting the AI service. Please try again.', 'accessibility-checker' ), + } ); + } finally { + setIsGenerating( false ); + } + }; + // Determine reading level status const getReadingLevelStatus = () => { if ( postGrade === undefined || postGrade === null ) { @@ -361,6 +395,19 @@ const ReadabilityAnalysis = () => {

{ __( 'Simplified Summary', 'accessibility-checker' ) }

+ { aiAvailable && ( + + ) } { + ) } {