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 } +
+ ) } + + ++ { __( 'Use your connected AI service to generate accessible alt text suggestions for this image.', 'accessibility-checker' ) } +
+ ) } + + { error && ( +