diff --git a/docs/experiments/content-translation.md b/docs/experiments/content-translation.md new file mode 100644 index 000000000..9bf0cacf5 --- /dev/null +++ b/docs/experiments/content-translation.md @@ -0,0 +1,294 @@ +# Content Translation + +## Summary + +The Content Translation experiment adds AI-powered block translation to the WordPress post editor. It provides a "Generate Translation" button in the post status panel, lets users choose a target language, and translates eligible blocks in the post one batch at a time. The experiment registers a WordPress Ability (`ai/content-translation`) that can be called from the editor UI or directly through the REST API. + +## Overview + +### For End Users + +When enabled, the Content Translation experiment adds a "Generate Translation" button to the post status panel in the WordPress post editor. Clicking the button opens a modal where users choose the target language and can optionally translate the post title. The experiment then translates supported text blocks and applies the translated content back to each block. + +**Key Features:** + +- One-click access from the post status panel +- Language picker for supported target languages +- Optional post title translation +- Block-by-block translation for `core/paragraph` and `core/heading` +- Batch processing with progress shown in the button label +- Partial success handling: failed blocks are counted and reported without discarding successful translations + +### For Developers + +The experiment consists of three main components: + +1. **Experiment Class** (`WordPress\AI\Experiments\Content_Translation\Content_Translation`): handles registration, asset enqueuing, localized editor settings, and ability registration. +2. **Ability Class** (`WordPress\AI\Abilities\Content_Translation\Content_Translation`): implements the translation logic through the WordPress Abilities API. +3. **Languages Class** (`WordPress\AI\Abilities\Content_Translation\Languages`): defines the supported target language list and exposes it to both PHP and JavaScript. + +The ability is block-agnostic: it translates any content string sent to it. The shipping editor UI limits translation to paragraph and heading blocks. + +## Architecture & Implementation + +### Input Schema + +```php +array( + 'type' => 'object', + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => 'The ID of the post to translate content for.', + ), + 'content' => array( + 'type' => 'string', + 'description' => 'The block content to translate.', + ), + 'target_language' => array( + 'type' => 'string', + 'enum' => Languages::get_codes(), + 'default' => Languages::get_default_target_language(), + 'sanitize_callback' => 'sanitize_key', + 'description' => 'The target language for translation.', + ), + ), +) +``` + +### Output Schema + +The ability returns a string with translated content: + +```php +array( + 'type' => 'string', + 'description' => 'The translated content.', +) +``` + +### Supported Languages + +The default target language is `en-us` (English US). The supported language list is: + +- `ar` - Arabic +- `zh-cn` - Chinese (Simplified) +- `zh-tw` - Chinese (Traditional) +- `nl-nl` - Dutch +- `en-gb` - English (UK) +- `en-us` - English (US) +- `fr-fr` - French +- `de-de` - German +- `hi` - Hindi +- `it-it` - Italian +- `ja` - Japanese +- `ko` - Korean +- `pt-br` - Portuguese (Brazil) +- `es-es` - Spanish + +The list is filterable with `wpai_content_translation_languages`. + +### Permissions + +The ability checks permissions based on the input: + +- **If `post_id` is provided:** + - Verifies the post exists; returns `post_not_found` otherwise. + - Checks `current_user_can( 'edit_post', $post_id )`. + - Requires the post type to have `show_in_rest` enabled. + +- **If `post_id` is not provided:** + - Checks `current_user_can( 'edit_posts' )`. + +## Using the Ability via REST API + +### Endpoint + +```text +POST /wp-json/wp-abilities/v1/abilities/ai/content-translation/run +``` + +### Authentication + +You can authenticate using either: + +1. **Application Password** (Recommended) +2. **Cookie Authentication with Nonce** + +See [TESTING_REST_API.md](../TESTING_REST_API.md) for detailed authentication instructions. + +### Request Examples + +#### Example 1: Translate a paragraph to French + +```bash +curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/content-translation/run" \ + -u "username:application-password" \ + -H "Content-Type: application/json" \ + -d '{ + "input": { + "content": "Our new platform helps teams collaborate more effectively, share files securely, and track progress in real time.", + "target_language": "fr-fr", + "post_id": 123 + } + }' +``` + +#### Example 2: Translate content with inline HTML + +```bash +curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/content-translation/run" \ + -u "username:application-password" \ + -H "Content-Type: application/json" \ + -d '{ + "input": { + "content": "Read the launch notes before publishing.", + "target_language": "es-es" + } + }' +``` + +The system instruction tells the model to preserve inline HTML such as links, emphasis, and code. + +#### Example 3: Use the default target language + +```bash +curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/content-translation/run" \ + -u "username:application-password" \ + -H "Content-Type: application/json" \ + -d '{ + "input": { + "content": "Translate this sentence using the default target language." + } + }' +``` + +When `target_language` is omitted, the ability uses `en-us`. + +#### Example 4: Using JavaScript (Fetch API) + +```javascript +const response = await fetch( + '/wp-json/wp-abilities/v1/abilities/ai/content-translation/run', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': wpApiSettings.nonce, // If using cookie auth + }, + credentials: 'include', // Include cookies for authentication + body: JSON.stringify( { + input: { + content: blockContent, + target_language: 'pt-br', + post_id: postId, + }, + } ), + } +); +``` + +#### Example 5: Using WordPress API Fetch (in Gutenberg/Admin) + +```javascript +import apiFetch from '@wordpress/api-fetch'; + +const translated = await apiFetch({ + path: '/wp-abilities/v1/abilities/ai/content-translation/run', + method: 'POST', + data: { + input: { + content: blockContent, + target_language: 'pt-br', + post_id: postId, + }, + }, +}); +``` + +### Error Responses + +The ability may return the following error codes: + +- `content_not_provided` - `content` was missing or empty. +- `content_too_short` - Content contains fewer than 1 word after stripping HTML. +- `invalid_target_language` - `target_language` is not in the supported language list. +- `post_not_found` - A `post_id` was supplied but the post does not exist. +- `insufficient_permissions` - Caller lacks `edit_post` (with `post_id`) or `edit_posts` (without). +- `no_results` - The AI client did not return translated text. +- A WP_Error from `ensure_text_generation_supported()` if no connected provider supports text generation. + +## Extending the Experiment + +### Customizing Supported Languages + +Use the `wpai_content_translation_languages` filter to replace or extend the target language list: + +```php +add_filter( 'wpai_content_translation_languages', function ( array $languages ): array { + $languages['sv'] = __( 'Swedish', 'my-plugin' ); + return $languages; +} ); +``` + +The filtered list is used for the ability schema, PHP validation, and the editor language picker. + +## Testing + +### Manual Testing + +1. **Enable the experiment:** + - Go to `Settings -> AI` + - Toggle **Content Translation** to enabled + - Ensure you have valid AI credentials configured + +2. **Test in the editor:** + - Create or edit a post with enough post content to meet the minimum length + - Open the post sidebar and click **Generate Translation** + - Choose a target language + - Toggle **Also translate the title** and click **Translate** + - Verify the title updates when the toggle is enabled + - Verify paragraph and heading blocks are replaced with translated text + - Verify the button shows progress while blocks are translating + +3. **Test disabled states:** + - Disable all experiments and verify the translation UI is hidden + - Disable only Content Translation and verify the translation UI is hidden + - Use content shorter than the minimum length and verify the button is disabled + +4. **Test REST API:** + - Use curl or Postman to test the REST endpoint + - Test each supported language code + - Verify `invalid_target_language` for an unsupported code + - Verify `post_not_found` and permission errors when using invalid or inaccessible posts + +## Notes & Considerations + +### Requirements + +- The experiment requires a configured AI connector/provider that supports text generation. +- Users must have `edit_post` when invoking with a `post_id`, or `edit_posts` when invoking without one. + +### Content Processing + +- The content sent to the model is wrapped in `` tags. +- The result is sanitized with `wp_kses_post()`. + +### System Instruction + +The system instruction guides the AI to: + +- Translate into the selected target language. +- Return only the translated text. +- Avoid preamble, explanation, or commentary. +- Preserve inline HTML and the original format where possible. +- Maintain the original perspective and voice. + +### Limitations + +- The editor UI only translates paragraph and heading blocks. +- There is no batch REST endpoint; the editor performs multiple ability calls in batches of 4. +- Translations are generated in real time and are not cached. +- Failed block translations are skipped; successful blocks remain applied. +- The UI replaces the current block content directly, so users should review changes before saving. diff --git a/includes/Abilities/Content_Translation/Content_Translation.php b/includes/Abilities/Content_Translation/Content_Translation.php new file mode 100644 index 000000000..ffa7c775c --- /dev/null +++ b/includes/Abilities/Content_Translation/Content_Translation.php @@ -0,0 +1,255 @@ + 'object', + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => esc_html__( 'The ID of the post to translate content for.', 'ai' ), + ), + 'content' => array( + 'type' => 'string', + 'description' => esc_html__( 'The block content to translate.', 'ai' ), + ), + 'target_language' => array( + 'type' => 'string', + 'enum' => Languages::get_codes(), + 'default' => Languages::get_default_target_language(), + 'sanitize_callback' => 'sanitize_key', + 'description' => esc_html__( 'The target language for translation.', 'ai' ), + ), + ), + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function output_schema(): array { + return array( + 'type' => 'string', + 'description' => esc_html__( 'The translated content.', 'ai' ), + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function execute_callback( $input ) { + // Default arguments for the translation process. + $args = wp_parse_args( + $input, + array( + 'post_id' => null, + 'content' => null, + 'target_language' => Languages::get_default_target_language(), + ) + ); + + // Skip normalization of content to retain HTML tags. + $content = $args['content'] ?? ''; + + if ( empty( $content ) ) { + return new WP_Error( + 'content_not_provided', + esc_html__( 'No content provided for translation.', 'ai' ) + ); + } + + if ( count_words( wp_strip_all_tags( $content ) ) < self::MIN_WORDS ) { + return new WP_Error( + 'content_too_short', + sprintf( + /* translators: %d: minimum number of words required for translation */ + esc_html__( 'A minimum of %d words is required for translation.', 'ai' ), + self::MIN_WORDS + ) + ); + } + + $prompt = sprintf( '%s', $content ); + + // Validate the target language. + $target_language = sanitize_key( (string) $args['target_language'] ); + $language = Languages::get_language_name( $target_language ); + if ( null === $language ) { + return new WP_Error( + 'invalid_target_language', + esc_html__( 'The specified target language is not supported for translation.', 'ai' ) + ); + } + + $result = $this->generate_translated_content( $prompt, $language ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + if ( empty( $result ) ) { + return new WP_Error( + 'no_results', + esc_html__( 'No translated content was generated.', 'ai' ) + ); + } + + return wp_kses_post( $result ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function permission_callback( $args ) { + // Ensure the user has permission to edit the post if a post ID is provided. + if ( isset( $args['post_id'] ) ) { + $post_id = absint( $args['post_id'] ); + $post = get_post( $post_id ); + + if ( ! $post ) { + return new WP_Error( + 'post_not_found', + sprintf( + /* translators: %d: post ID */ + esc_html__( 'Post with ID %d not found.', 'ai' ), + $post_id + ) + ); + } + + if ( ! current_user_can( 'edit_post', $post_id ) ) { + return new WP_Error( + 'insufficient_permissions', + esc_html__( 'You do not have permission to edit this post.', 'ai' ) + ); + } + + // Ensure the post type is allowed in REST endpoints. + $post_type_obj = get_post_type_object( $post->post_type ); + + if ( ! $post_type_obj || empty( $post_type_obj->show_in_rest ) ) { + return false; + } + } elseif ( ! current_user_can( 'edit_posts' ) ) { + return new WP_Error( + 'insufficient_permissions', + esc_html__( 'You do not have permission to translate content.', 'ai' ) + ); + } + + return true; + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function meta(): array { + return array( + 'show_in_rest' => true, + ); + } + + /** + * Generates translated content using the AI Client. + * + * @since x.x.x + * + * @param string $prompt The prompt to use for the content translation. + * @param string $language The target language for the translation. + * @return string|\WP_Error The translated content, or a WP_Error if there was an error. + */ + protected function generate_translated_content( string $prompt, string $language ) { + $builder = $this->get_prompt_builder( $prompt, $language ); + + if ( is_wp_error( $builder ) ) { + return $builder; + } + + return $builder->generate_text(); + } + + /** + * Returns a prompt builder for content translation. + * + * @since x.x.x + * + * @param string $prompt The prompt to build. + * @param string $language The target language. + * @return \WP_AI_Client_Prompt_Builder|\WP_Error The prompt builder, or a WP_Error if there isn't a model that supports text generation. + */ + private function get_prompt_builder( string $prompt, string $language ) { + $prompt_builder = wp_ai_client_prompt( $prompt ) + ->using_system_instruction( + $this->get_system_instruction( + 'system-instruction.php', + array( + 'target_language' => $language, + ) + ) + ) + ->using_temperature( 0.7 ); + + $prompt_builder = $this->set_provider_model_preference( + $prompt_builder, + Content_Translation_Experiment::class + ); + + return $this->ensure_text_generation_supported( + $prompt_builder, + esc_html__( 'Content translation failed. Please ensure you have a connected provider that supports text generation.', 'ai' ) + ); + } +} diff --git a/includes/Abilities/Content_Translation/Languages.php b/includes/Abilities/Content_Translation/Languages.php new file mode 100644 index 000000000..52272d8f7 --- /dev/null +++ b/includes/Abilities/Content_Translation/Languages.php @@ -0,0 +1,134 @@ + Supported languages. + */ + public static function get_supported_languages(): array { + $languages = array( + 'ar' => __( 'Arabic', 'ai' ), + 'zh-cn' => __( 'Chinese (Simplified)', 'ai' ), + 'zh-tw' => __( 'Chinese (Traditional)', 'ai' ), + 'nl-nl' => __( 'Dutch', 'ai' ), + 'en-gb' => __( 'English (UK)', 'ai' ), + 'en-us' => __( 'English (US)', 'ai' ), + 'fr-fr' => __( 'French', 'ai' ), + 'de-de' => __( 'German', 'ai' ), + 'hi' => __( 'Hindi', 'ai' ), + 'it-it' => __( 'Italian', 'ai' ), + 'ja' => __( 'Japanese', 'ai' ), + 'ko' => __( 'Korean', 'ai' ), + 'pt-br' => __( 'Portuguese (Brazil)', 'ai' ), + 'es-es' => __( 'Spanish', 'ai' ), + ); + + /** + * Filters supported target languages for AI content translation. + * + * @param array $languages Supported languages. + */ + return (array) apply_filters( 'wpai_content_translation_languages', $languages ); + } + + /** + * Returns the supported languages for AI content translation in a format suitable for JavaScript. + * + * @since x.x.x + * + * @return array Supported languages for JavaScript. + */ + public static function get_supported_languages_for_js(): array { + $languages = self::get_supported_languages(); + + return array_map( + static function ( string $code, string $name ): array { + return array( + 'code' => $code, + 'name' => $name, + ); + }, + array_keys( $languages ), + $languages + ); + } + + /** + * Returns the name of a language given its code. + * + * @since x.x.x + * + * @param string $language_code The language code. + * @return string|null The name of the language, or null if not found. + */ + public static function get_language_name( string $language_code ): ?string { + $languages = self::get_supported_languages(); + + if ( array_key_exists( $language_code, $languages ) ) { + return $languages[ $language_code ]; + } + + return null; + } + + /** + * Returns the language codes of supported languages. + * + * @since x.x.x + * + * @return string[] Array of supported language codes. + */ + public static function get_codes(): array { + return array_keys( self::get_supported_languages() ); + } + + /** + * Checks if a language is supported for translation. + * + * @since x.x.x + * + * @param string $language_code The language code to check. + * @return bool True if the language is supported, false otherwise. + */ + public static function is_supported( string $language_code ): bool { + return array_key_exists( $language_code, self::get_supported_languages() ); + } +} diff --git a/includes/Abilities/Content_Translation/system-instruction.php b/includes/Abilities/Content_Translation/system-instruction.php new file mode 100644 index 000000000..893f3d04b --- /dev/null +++ b/includes/Abilities/Content_Translation/system-instruction.php @@ -0,0 +1,28 @@ +` tags) into a different language while preserving meaning and intent. The content may contain inline HTML tags (such as strong, em, a, code). + +Goal: Translate the content into {$target_language}. Ensure that the translation is accurate, natural, and fluent in {$target_language}. + +Requirements: +- Return only the translated text, nothing else. +- Do not include any preamble, explanation, or commentary. +- Return content in the same format as it was provided. For example, preserve any inline HTML like links. +- Match the target language specified. For example, if the target language is {$target_language}, return the content in {$target_language}. +- Maintain the original perspective and voice. +INSTRUCTION; diff --git a/includes/Experiments/Content_Translation/Content_Translation.php b/includes/Experiments/Content_Translation/Content_Translation.php new file mode 100644 index 000000000..1b9f8fc7c --- /dev/null +++ b/includes/Experiments/Content_Translation/Content_Translation.php @@ -0,0 +1,129 @@ + __( 'Content Translation', 'ai' ), + 'description' => __( 'Translate block content into a different language. Requires an AI connector that includes support for text generation models.', 'ai' ), + 'category' => Experiment_Category::EDITOR, + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + public function register(): void { + add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); + add_action( 'enqueue_block_assets', array( $this, 'enqueue_block_assets' ) ); + } + + /** + * Registers required abilities + * + * @since x.x.x + */ + public function register_abilities(): void { + wp_register_ability( + sprintf( 'ai/%s', $this->get_id() ), + array( + 'label' => $this->get_label(), + 'description' => $this->get_description(), + 'ability_class' => Content_Translation_Ability::class, + ) + ); + } + + /** + * Enqueues and localizes the admin script. + * + * @since x.x.x + * + * @param string $hook_suffix The current admin page hook suffix. + */ + public function enqueue_assets( string $hook_suffix ): void { + // Enqueue assets only on the post editor screen. + if ( 'post.php' !== $hook_suffix && 'post-new.php' !== $hook_suffix ) { + return; + } + + Asset_Loader::enqueue_script( + 'content_translation', + 'experiments/content-translation', + array( + 'include_core_abilities' => true, + ) + ); + + Asset_Loader::enqueue_style( + 'content_translation', + 'experiments/content-translation' + ); + + Asset_Loader::localize_script( + 'content_translation', + 'ContentTranslationData', + array( + 'enabled' => $this->is_enabled(), + 'minContentLength' => get_min_content_length( 'content-translation', 15 ), + 'languages' => Languages::get_supported_languages_for_js(), + ) + ); + } + + /** + * Enqueues the block stylesheet for the editor canvas. + * + * @since x.x.x + */ + public function enqueue_block_assets(): void { + Asset_Loader::enqueue_style( + 'content_translation', + 'experiments/content-translation' + ); + } +} diff --git a/includes/Experiments/Experiments.php b/includes/Experiments/Experiments.php index 61a35d85a..68c629aef 100644 --- a/includes/Experiments/Experiments.php +++ b/includes/Experiments/Experiments.php @@ -40,6 +40,7 @@ final class Experiments { \WordPress\AI\Experiments\Summarization\Summarization::class, \WordPress\AI\Experiments\Title_Generation\Title_Generation::class, \WordPress\AI\Experiments\Comment_Moderation\Comment_Moderation::class, + \WordPress\AI\Experiments\Content_Translation\Content_Translation::class, ); /** diff --git a/src/experiments/content-translation/components/ContentTranslationPlugin.tsx b/src/experiments/content-translation/components/ContentTranslationPlugin.tsx new file mode 100644 index 000000000..1d0c1c9df --- /dev/null +++ b/src/experiments/content-translation/components/ContentTranslationPlugin.tsx @@ -0,0 +1,97 @@ +/** + * WordPress dependencies + */ +import { Button, Icon } from '@wordpress/components'; +import { PluginPostStatusInfo } from '@wordpress/editor'; +import { __, sprintf } from '@wordpress/i18n'; +import { Stack, Text } from '@wordpress/ui'; +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import TranslationModal from './TranslationModal'; +import { useContentTranslation } from '../hooks/useContentTranslation'; +import { formatMinLengthLabel } from '../../../utils/word-count'; +import { getSettings } from '../utils'; + +export default function ContentTranslationPlugin() { + const [ isOpen, setIsOpen ] = useState( false ); + const { + isLoading: isTranslating, + isContentTooShort, + progress, + total, + minContentLength, + translate, + } = useContentTranslation(); + + if ( ! getSettings().enabled ) { + return null; + } + + const buttonLabel = isTranslating + ? sprintf( + /* translators: %1$d: number of translated blocks, %2$d: total number of blocks */ + __( 'Translating blocks… (%1$d/%2$d)', 'ai' ), + progress, + total + ) + : __( 'Generate Translation', 'ai' ); + + const buttonDescription = isContentTooShort + ? formatMinLengthLabel( + /* translators: %d: minimum number of characters required */ + __( + 'Content translation will be available when the post content has at least %d characters.', + 'ai' + ), + /* translators: %d: minimum number of words required */ + __( + 'Content translation will be available when the post content has at least %d words.', + 'ai' + ), + minContentLength + ) + : __( + 'Translates this post block by block and applies the translated content to each block.', + 'ai' + ); + + return ( + + + + + { isOpen && ( + setIsOpen( false ) } + translate={ translate } + /> + ) } + + + { buttonDescription } + + + + ); +} diff --git a/src/experiments/content-translation/components/TranslationModal.tsx b/src/experiments/content-translation/components/TranslationModal.tsx new file mode 100644 index 000000000..3397a8bee --- /dev/null +++ b/src/experiments/content-translation/components/TranslationModal.tsx @@ -0,0 +1,106 @@ +/** + * WordPress dependencies + */ +import { + Button, + Modal, + SelectControl, + ToggleControl, +} from '@wordpress/components'; +import { useMemo, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { Stack } from '@wordpress/ui'; + +/** + * Internal dependencies + */ +import { getSettings } from '../utils'; + +type TranslationModalProps = { + canTranslate: boolean; + closeModal: () => void; + translate: ( + languageCode: string, + options?: { translateTitle?: boolean } + ) => Promise< void >; +}; + +/** + * TranslationModal component. + * + * @param props Component props. + * @param props.canTranslate Whether translation can be started. + * @param props.closeModal Callback to close the modal. + * @param props.translate Callback to translate content. + */ +export default function TranslationModal( { + canTranslate, + closeModal, + translate, +}: TranslationModalProps ) { + const controls = useMemo( + () => + getSettings().languages.map( ( language ) => ( { + label: language.name, + value: language.code, + } ) ), + [] + ); + + const [ translateTitle, setTranslateTitle ] = useState( false ); + const [ selectedLanguage, setSelectedLanguage ] = useState( + controls?.[ 0 ]?.value || '' + ); + + return ( + + + setSelectedLanguage( value ) } + __next40pxDefaultSize + /> + + setTranslateTitle( value ) } + checked={ translateTitle } + /> + + + + + + + + ); +} diff --git a/src/experiments/content-translation/constants.ts b/src/experiments/content-translation/constants.ts new file mode 100644 index 000000000..6e9e30e61 --- /dev/null +++ b/src/experiments/content-translation/constants.ts @@ -0,0 +1,30 @@ +/** + * A default minimum content length for enabling content translation. + */ +export const TRANSLATION_MINIMUM_CONTENT_COUNT_DEFAULT = 15; + +/** + * Notice ID for the content translation error notice. + */ +export const TRANSLATION_NOTICE_ID = 'ai_content_translation'; + +/** + * Batch size for content translation. + */ +export const TRANSLATION_BATCH_SIZE = 4; + +/** + * Supported block types for content translation. + */ +export const TRANSLATION_SUPPORTED_BLOCK_TYPES = [ + 'core/paragraph', + 'core/heading', +]; + +/** + * Loading classes for the content translation process. + */ +export const TRANSLATION_LOADING_CLASSES = { + TITLE: 'ai-content-translation--is-title-loading', + BLOCKS: 'ai-content-translation--is-blocks-loading', +} as const; diff --git a/src/experiments/content-translation/hooks/useContentTranslation.ts b/src/experiments/content-translation/hooks/useContentTranslation.ts new file mode 100644 index 000000000..534202423 --- /dev/null +++ b/src/experiments/content-translation/hooks/useContentTranslation.ts @@ -0,0 +1,280 @@ +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { store as editorStore } from '@wordpress/editor'; +import { store as noticesStore } from '@wordpress/notices'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { __, _n, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { ensureProvider } from '../../../utils/provider-status'; +import { flattenBlocks } from '../../../utils/blocks'; +import { hasMinimumContent } from '../../../utils/word-count'; +import { + getErrorMessage, + getSettings, + getTranslatableBlock, + setTranslationLoadingClass, + translateContent, +} from '../utils'; +import { TRANSLATION_BATCH_SIZE, TRANSLATION_NOTICE_ID } from '../constants'; + +type UseContentTranslationReturn = { + isContentTooShort: boolean; + isLoading: boolean; + progress: number; + total: number; + minContentLength: number; + translate: ( + languageCode: string, + options?: TranslateOptions + ) => Promise< void >; +}; + +type TranslateOptions = { + translateTitle?: boolean; +}; + +// Notice IDs for the content translation process. +const TRANSLATION_NOTICE_ID_TITLE = `${ TRANSLATION_NOTICE_ID }_title`; +const TRANSLATION_NOTICE_ID_CONTENT = `${ TRANSLATION_NOTICE_ID }_content`; + +/** + * Handles the content translation process, including managing loading state, progress, and error handling. + * + * @return An object with the translation state and functions. + */ +export function useContentTranslation(): UseContentTranslationReturn { + const [ isTitleTranslating, setIsTitleTranslating ] = useState( false ); + const [ isContentTranslating, setIsContentTranslating ] = useState( false ); + const [ progress, setProgress ] = useState( 0 ); + const [ total, setTotal ] = useState( 0 ); + + const noticeDispatch = useDispatch( noticesStore ); + const blockEditorDispatch = useDispatch( blockEditorStore ); + const editorDispatch = useDispatch( editorStore ); + + const { postId, allBlocks, title, content } = useSelect( ( sel ) => { + return { + postId: sel( editorStore ).getCurrentPostId() as number, + allBlocks: sel( blockEditorStore ).getBlocks(), + title: sel( editorStore ).getEditedPostAttribute( + 'title' + ) as string, + content: sel( editorStore ).getEditedPostContent(), + }; + }, [] ); + + const isContentTooShort = ! hasMinimumContent( + content || '', + getSettings().minContentLength + ); + + /** + * Translates the content of a post. + * + * @param languageCode The code of the language to translate the post to. + * @param options The options for the translation. + * @return A promise that resolves when the translation is complete. + */ + const translate = async ( + languageCode: string, + options?: TranslateOptions + ) => { + const { translateTitle = false } = options || {}; + + // Remove any existing error notices. + noticeDispatch.removeNotice( TRANSLATION_NOTICE_ID ); + noticeDispatch.removeNotice( TRANSLATION_NOTICE_ID_TITLE ); + noticeDispatch.removeNotice( TRANSLATION_NOTICE_ID_CONTENT ); + + if ( ! ensureProvider( TRANSLATION_NOTICE_ID ) ) { + return; + } + + if ( isContentTooShort ) { + return; + } + + setIsContentTranslating( true ); + setTranslationLoadingClass( 'BLOCKS', true ); + + try { + if ( translateTitle ) { + // Title translation is optional. If it fails, continue translating the post + // content and show a warning so the user can retry the title separately. + await translatePostTitle( languageCode ); + } + + await translateBlocksContent( languageCode ); + } catch ( error ) { + noticeDispatch.createErrorNotice( getErrorMessage( error ), { + id: TRANSLATION_NOTICE_ID_CONTENT, + } ); + } finally { + setIsContentTranslating( false ); + setTranslationLoadingClass( 'BLOCKS', false ); + setProgress( 0 ); + setTotal( 0 ); + } + }; + + /** + * Translates and updates the title of a post. + * + * @param languageCode The code of the language to translate the post to. + * @return A promise that resolves when the translation and updates are complete. + */ + const translatePostTitle = async ( languageCode: string ) => { + if ( title.trim().length === 0 ) { + noticeDispatch.createWarningNotice( + __( 'Cannot translate an empty post title.', 'ai' ), + { + id: TRANSLATION_NOTICE_ID_TITLE, + } + ); + + return; + } + + try { + setIsTitleTranslating( true ); + setTranslationLoadingClass( 'TITLE', true ); + + const translatedTitle = await translateContent( + title, + languageCode, + postId + ); + + editorDispatch.editPost( { + title: translatedTitle, + } ); + } catch ( error ) { + noticeDispatch.createWarningNotice( getErrorMessage( error ), { + id: TRANSLATION_NOTICE_ID_TITLE, + } ); + } finally { + setIsTitleTranslating( false ); + setTranslationLoadingClass( 'TITLE', false ); + } + }; + + /** + * Translates and updates the content of the blocks in the post. + * + * @param languageCode The code of the language to translate the post to. + * @return A promise that resolves when the translation and updates are complete. + */ + const translateBlocksContent = async ( languageCode: string ) => { + setProgress( 0 ); + setTotal( 0 ); + + const translatableBlocks = flattenBlocks( allBlocks ) + .map( ( block ) => getTranslatableBlock( block ) ) + .filter( ( block ) => block !== null ); + + if ( translatableBlocks.length === 0 ) { + noticeDispatch.createErrorNotice( + __( 'No translatable content found in the post.', 'ai' ), + { + id: TRANSLATION_NOTICE_ID_CONTENT, + } + ); + + return; + } + + setTotal( translatableBlocks.length ); + + // Count the number of blocks that failed to be translated. + let failedBlocksCount = 0; + + // Process blocks in batches. + for ( + let batchStart = 0; + batchStart < translatableBlocks.length; + batchStart += TRANSLATION_BATCH_SIZE + ) { + const batch = translatableBlocks.slice( + batchStart, + batchStart + TRANSLATION_BATCH_SIZE + ); + + // Use allSettled so failed block translations do not prevent successful + // translations from being applied, avoiding wasted tokens from discarding + // the whole batch. + const results = await Promise.allSettled( + batch.map( ( block ) => + translateContent( block.content, languageCode, postId ) + ) + ); + + results.forEach( ( result, index ) => { + if ( result.status === 'rejected' ) { + failedBlocksCount++; + return; + } + + // This should not happen, but keep the index access guarded in case the + // result list and batch ever diverge. + if ( ! batch[ index ] ) { + failedBlocksCount++; + return; + } + + // If the translation is empty, failedBlocksCount is incremented and the + // block is skipped. + if ( + ! result.value || + typeof result.value !== 'string' || + ! result.value.trim().length + ) { + failedBlocksCount++; + return; + } + + const { clientId } = batch[ index ]; + blockEditorDispatch.updateBlockAttributes( clientId, { + content: result.value, + } ); + } ); + + setProgress( + Math.min( + batchStart + TRANSLATION_BATCH_SIZE, + translatableBlocks.length + ) + ); + } + + if ( failedBlocksCount > 0 ) { + noticeDispatch.createWarningNotice( + sprintf( + /* translators: %d: number of blocks that failed to be translated. */ + _n( + 'Failed to translate %d block.', + 'Failed to translate %d blocks.', + failedBlocksCount, + 'ai' + ), + failedBlocksCount + ), + { id: TRANSLATION_NOTICE_ID_CONTENT } + ); + } + }; + + return { + isLoading: isTitleTranslating || isContentTranslating, + isContentTooShort, + progress, + total, + minContentLength: getSettings().minContentLength, + translate, + }; +} diff --git a/src/experiments/content-translation/index.scss b/src/experiments/content-translation/index.scss new file mode 100644 index 000000000..f3de0ab72 --- /dev/null +++ b/src/experiments/content-translation/index.scss @@ -0,0 +1,44 @@ +@keyframes ai-content-translation-text-shimmer { + from { + background-position: 300% 0; + } + to { + background-position: 0 0; + } +} + +.ai-content-translation--is-title-loading .wp-block-post-title, +.ai-content-translation--is-blocks-loading .block-editor-rich-text__editable { + color: var(--wpds-color-fg-content-neutral-weak, #707070); + + @media (prefers-reduced-motion: no-preference) { + background: linear-gradient( + 90deg, + var(--wpds-color-fg-content-neutral-weak, #707070) 42%, + color-mix( + in srgb, + var(--wpds-color-fg-content-neutral-weak, #707070) 55%, + #fff + ) + 50%, + var(--wpds-color-fg-content-neutral-weak, #707070) 58% + ); + background-size: 300% 100%; + animation: ai-content-translation-text-shimmer 1.8s linear infinite; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + will-change: background-position; + } +} + +.ai-content-translation-plugin { + &__trigger.components-button.has-icon.has-text { + width: 100%; + justify-content: center; + } + + &__description { + color: var(--wpds-color-fg-content-neutral-weak, #757575); + } +} diff --git a/src/experiments/content-translation/index.tsx b/src/experiments/content-translation/index.tsx new file mode 100644 index 000000000..29e062d5e --- /dev/null +++ b/src/experiments/content-translation/index.tsx @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ +import { registerPlugin } from '@wordpress/plugins'; + +/** + * Internal dependencies + */ +import ContentTranslationPlugin from './components/ContentTranslationPlugin'; +import './index.scss'; + +registerPlugin( 'ai-content-translation', { + render: () => , +} ); diff --git a/src/experiments/content-translation/types/index.ts b/src/experiments/content-translation/types/index.ts new file mode 100644 index 000000000..1f355465e --- /dev/null +++ b/src/experiments/content-translation/types/index.ts @@ -0,0 +1,14 @@ +export type AIContentTranslationData = { + enabled: boolean; + minContentLength: number; + languages: Array< { + code: string; + name: string; + } >; +}; + +declare global { + interface Window { + aiContentTranslationData: AIContentTranslationData; + } +} diff --git a/src/experiments/content-translation/utils/index.ts b/src/experiments/content-translation/utils/index.ts new file mode 100644 index 000000000..82f0cc21f --- /dev/null +++ b/src/experiments/content-translation/utils/index.ts @@ -0,0 +1,114 @@ +/** + * WordPress dependencies + */ +import type { Block } from '@wordpress/blocks'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { getBlockText } from '../../../utils/blocks'; +import { runAbility } from '../../../utils/run-ability'; +import type { AIContentTranslationData } from '../types'; +import { + TRANSLATION_LOADING_CLASSES, + TRANSLATION_MINIMUM_CONTENT_COUNT_DEFAULT, + TRANSLATION_SUPPORTED_BLOCK_TYPES, +} from '../constants'; + +/** + * Retrieves the content translation settings from the global window object. + * + * @return The content translation settings. + */ +export const getSettings = (): AIContentTranslationData => { + const settings = window?.aiContentTranslationData ?? {}; + + return { + enabled: settings.enabled ?? false, + minContentLength: + settings.minContentLength ?? + TRANSLATION_MINIMUM_CONTENT_COUNT_DEFAULT, + languages: settings.languages ?? [], + }; +}; + +/** + * Get the translatable block if it is supported and has non-empty text content. + * + * @param block The block to check. + * @return An object containing the clientId and content of the block, or null if the block is not translatable. + */ +export function getTranslatableBlock( block: Block ) { + const content = getBlockText( block ); + + if ( + TRANSLATION_SUPPORTED_BLOCK_TYPES.includes( block.name ) && + content.trim().length > 0 + ) { + return { + clientId: block.clientId, + content, + }; + } + + return null; +} + +/** + * Toggle the loading class used to show the translation-in-progress state. + * + * @param loadingClass The loading class to toggle. + * @param isLoading A boolean indicating whether the loading class should be toggled. + */ +export function setTranslationLoadingClass( + loadingClass: keyof typeof TRANSLATION_LOADING_CLASSES, + isLoading: boolean +) { + const editorBody = document.querySelector< HTMLIFrameElement >( + 'iframe[name="editor-canvas"]' + )?.contentDocument?.body; + + editorBody?.classList.toggle( + TRANSLATION_LOADING_CLASSES[ loadingClass ], + isLoading + ); +} + +/** + * Get a user-friendly error message from an unknown error object. + * + * @param error The unknown error object to extract the message from. + * @return A string containing the error message to display to the user. + */ +export function getErrorMessage( error: unknown ): string { + if ( typeof error === 'string' ) { + return error; + } + + if ( error && typeof error === 'object' && 'message' in error ) { + return String( ( error as { message: string } ).message ); + } + + return __( 'Something went wrong. Please try again.', 'ai' ); +} + +/** + * Translates the content of a post using the AI API. + * + * @param content The content to translate. + * @param targetLanguage The target language to translate the content to. + * @param postId The ID of the post to translate the content for. + * @return A promise that resolves to the translated content. + */ +export function translateContent( + content: string, + targetLanguage: string, + postId: number +): Promise< string > { + return runAbility< string >( 'ai/content-translation', { + content, + target_language: targetLanguage, + post_id: postId, + } ); +} diff --git a/tests/Integration/Includes/Abilities/Content_TranslationTest.php b/tests/Integration/Includes/Abilities/Content_TranslationTest.php new file mode 100644 index 000000000..4709493e5 --- /dev/null +++ b/tests/Integration/Includes/Abilities/Content_TranslationTest.php @@ -0,0 +1,555 @@ + 'Content Translation', + 'description' => 'Translate block content into a different language. Requires an AI connector that includes support for text generation models.', + ); + } + + /** + * Registers the experiment. + * + * @since x.x.x + */ + public function register(): void { + // No-op for testing. + } +} + +/** + * Content Translation Ability test case. + * + * @since x.x.x + */ +class Content_TranslationTest extends WP_UnitTestCase { + + /** + * Content Translation ability instance. + * + * @var \WordPress\AI\Abilities\Content_Translation\Content_Translation + */ + private $ability; + + /** + * Test experiment instance. + * + * @var \WordPress\AI\Tests\Integration\Includes\Abilities\Test_Content_Translation_Experiment + */ + private $experiment; + + /** + * Set up test case. + * + * @since x.x.x + */ + public function setUp(): void { + parent::setUp(); + + $this->experiment = new Test_Content_Translation_Experiment(); + $this->ability = new Content_Translation( + 'ai/content-translation', + array( + 'label' => $this->experiment->get_label(), + 'description' => $this->experiment->get_description(), + ) + ); + } + + /** + * Tear down test case. + * + * @since x.x.x + */ + public function tearDown(): void { + wp_set_current_user( 0 ); + parent::tearDown(); + } + + /** + * Test that guideline_categories() returns site and copy. + * + * @since x.x.x + */ + public function test_guideline_categories_returns_site_and_copy(): void { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'guideline_categories' ); + $method->setAccessible( true ); + + $this->assertSame( + array( 'site', 'copy' ), + $method->invoke( $this->ability ) + ); + } + + /** + * Test that category() returns the correct category. + * + * @since x.x.x + */ + public function test_category_returns_correct_category(): void { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'category' ); + + $method->setAccessible( true ); + $result = $method->invoke( $this->ability ); + + $this->assertEquals( 'ai-experiments', $result, 'Category should be ai-experiments' ); + } + + /** + * Test that input_schema() returns the expected schema structure. + * + * @since x.x.x + */ + public function test_input_schema_returns_expected_structure(): void { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'input_schema' ); + + $method->setAccessible( true ); + $schema = $method->invoke( $this->ability ); + + $this->assertIsArray( $schema, 'Input schema should be an array' ); + $this->assertEquals( 'object', $schema['type'], 'Schema type should be object' ); + $this->assertArrayHasKey( 'properties', $schema, 'Schema should have properties' ); + + $this->assertArrayHasKey( + 'post_id', + $schema['properties'], + 'Schema should have post_id property' + ); + $this->assertEquals( + 'integer', + $schema['properties']['post_id']['type'], + 'Post ID should be integer type' + ); + $this->assertEquals( + 'absint', + $schema['properties']['post_id']['sanitize_callback'], + 'Post ID should use absint' + ); + + $this->assertArrayHasKey( + 'content', + $schema['properties'], + 'Schema should have content property' + ); + $this->assertEquals( + 'string', + $schema['properties']['content']['type'], + 'Content should be string type' + ); + + $this->assertArrayHasKey( + 'target_language', + $schema['properties'], + 'Schema should have target_language property' + ); + $this->assertEquals( + 'string', + $schema['properties']['target_language']['type'], + 'Target language should be string type' + ); + $this->assertEquals( + Languages::get_codes(), + $schema['properties']['target_language']['enum'], + sprintf( 'Target language should be enum with values %s', implode( ', ', Languages::get_codes() ) ) + ); + $this->assertEquals( + Languages::get_default_target_language(), + $schema['properties']['target_language']['default'], + sprintf( 'Target language default should be %s', Languages::get_default_target_language() ) + ); + $this->assertEquals( + 'sanitize_key', + $schema['properties']['target_language']['sanitize_callback'], + 'Target language should use sanitize_key' + ); + } + + /** + * Test that output_schema() returns the expected schema structure. + * + * @since x.x.x + */ + public function test_output_schema_returns_expected_structure(): void { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'output_schema' ); + + $method->setAccessible( true ); + $schema = $method->invoke( $this->ability ); + + $this->assertIsArray( $schema, 'Output schema should be an array' ); + $this->assertEquals( 'string', $schema['type'], 'Schema type should be string' ); + $this->assertArrayHasKey( 'description', $schema, 'Schema should have description' ); + } + + /** + * Test that get_system_instruction() returns the system instruction. + * + * @since x.x.x + */ + public function test_get_system_instruction_returns_system_instruction(): void { + $system_instruction = $this->ability->get_system_instruction(); + + $this->assertIsString( $system_instruction, 'System instruction should be a string' ); + $this->assertNotEmpty( $system_instruction, 'System instruction should not be empty' ); + } + + /** + * Test that execute_callback() returns error when content is missing. + * + * @since x.x.x + */ + public function test_execute_callback_without_content(): void { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + + $method->setAccessible( true ); + $result = $method->invoke( $this->ability, array() ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'content_not_provided', $result->get_error_code(), 'Error code should be content_not_provided' ); + } + + /** + * Test that execute_callback() returns error when content is empty. + * + * @since x.x.x + */ + public function test_execute_callback_with_empty_content(): void { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + + $method->setAccessible( true ); + $result = $method->invoke( $this->ability, array( 'content' => '' ) ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'content_not_provided', $result->get_error_code(), 'Error code should be content_not_provided' ); + } + + /** + * Test that execute_callback() returns error when content is too short. + * + * @since x.x.x + */ + public function test_execute_callback_with_too_short_content(): void { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + + $method->setAccessible( true ); + $result = $method->invoke( $this->ability, array( 'content' => '' ) ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'content_too_short', $result->get_error_code(), 'Error code should be content_too_short' ); + } + + /** + * Test that execute_callback() returns error when target language is invalid. + * + * @since x.x.x + */ + public function test_execute_callback_with_invalid_target_language(): void { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + + $method->setAccessible( true ); + $result = $method->invoke( + $this->ability, + array( + 'content' => 'A content to translate.', + 'target_language' => 'invalid', + ) + ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( + 'invalid_target_language', + $result->get_error_code(), + 'Error code should be invalid_target_language' + ); + } + + /** + * Test that execute_callback() returns the translated content. + * + * @since x.x.x + */ + public function test_execute_callback_returns_translated_content(): void { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + + $method->setAccessible( true ); + + try { + $result = $method->invoke( + $this->ability, + array( + 'content' => 'A content to translate.', + 'target_language' => 'pt-br', + ) + ); + } catch ( \Throwable $e ) { + $this->markTestSkipped( + sprintf( + 'AI client not available in test environment: %s', + $e->getMessage() + ) + ); + return; + } + + if ( is_wp_error( $result ) ) { + $this->markTestSkipped( + sprintf( + 'AI client not available in test environment: %s', + $result->get_error_message() + ) + ); + return; + } + + $this->assertIsString( $result, 'Result should be a string' ); + $this->assertNotEmpty( $result, 'Result should not be empty' ); + } + + /** + * Test that permission_callback() returns true for user with edit_posts capability. + * + * @since x.x.x + */ + public function test_permission_callback_with_edit_posts_capability(): void { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + $user_id = $this->factory()->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array() ); + $this->assertTrue( + $result, + 'Permission should be granted for user with edit_posts capability' + ); + } + + /** + * Test that permission_callback() returns error when post corresponding to the provided post ID is not found. + * + * @since x.x.x + */ + public function test_permission_callback_with_nonexistent_post_id(): void { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + $result = $method->invoke( $this->ability, array( 'post_id' => 99999 ) ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'post_not_found', $result->get_error_code(), 'Error code should be post_not_found' ); + } + + /** + * Test that permission_callback() returns error when user does not have edit_post capability. + * + * @since x.x.x + */ + public function test_permission_callback_with_user_without_edit_post_capability(): void { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + $post_id = $this->factory()->post->create(); + + $user_id = $this->factory()->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array( 'post_id' => $post_id ) ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'insufficient_permissions', $result->get_error_code(), 'Error code should be insufficient_permissions' ); + } + + /** + * Test that permission_callback() returns true when post ID is provided and user + * can edit the post. + * + * @since x.x.x + */ + public function test_permission_callback_with_post_id_and_edit_post_capability(): void { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + $post_id = $this->factory()->post->create( + array( + 'post_content' => 'Test content', + 'post_type' => 'post', + 'post_status' => 'publish', + ) + ); + + $user_id = $this->factory()->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array( 'post_id' => $post_id ) ); + + $this->assertTrue( + $result, + 'Permission should be granted for user with edit_post capability' + ); + } + + /** + * Test that permission_callback() returns error for user without edit_posts capability. + * + * @since x.x.x + */ + public function test_permission_callback_without_edit_posts_capability(): void { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + $user_id = $this->factory()->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array() ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( + 'insufficient_permissions', + $result->get_error_code(), + 'Error code should be insufficient_permissions' + ); + } + + /** + * Test that permission_callback() returns error for logged out user. + * + * @since x.x.x + */ + public function test_permission_callback_for_logged_out_user(): void { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + wp_set_current_user( 0 ); + + $result = $method->invoke( $this->ability, array() ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( + 'insufficient_permissions', + $result->get_error_code(), + 'Error code should be insufficient_permissions' + ); + } + + /** + * Test that permission_callback() returns error for post type without show_in_rest. + * + * @since x.x.x + */ + public function test_permission_callback_with_post_type_without_show_in_rest(): void { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + register_post_type( + 'test_no_rest', + array( + 'public' => true, + 'show_in_rest' => false, + ) + ); + + $post_id = $this->factory()->post->create( + array( + 'post_content' => 'Test content', + 'post_type' => 'test_no_rest', + 'post_status' => 'publish', + ) + ); + + $user_id = $this->factory()->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array( 'post_id' => $post_id ) ); + + $this->assertFalse( + $result, + 'Permission should be denied for post type without show_in_rest' + ); + + unregister_post_type( 'test_no_rest' ); + } + + /** + * Test that meta() returns the expected meta structure. + * + * @since x.x.x + */ + public function test_meta_returns_expected_structure(): void { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'meta' ); + + $method->setAccessible( true ); + $meta = $method->invoke( $this->ability ); + + $this->assertIsArray( $meta, 'Meta should be an array' ); + $this->assertArrayHasKey( 'show_in_rest', $meta, 'Meta should have show_in_rest' ); + $this->assertTrue( $meta['show_in_rest'], 'show_in_rest should be true' ); + } + + /** + * Test that generate_translated_content() returns WP_Error when no supported model is available. + * + * This is the expected out-of-the-box result in the test environment, where no + * AI provider/model is configured. + * + * @since x.x.x + */ + public function test_generate_translated_content_returns_wp_error_when_no_supported_model_is_available(): void { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'generate_translated_content' ); + $method->setAccessible( true ); + + $result = $method->invoke( + $this->ability, + 'Test content to translate.', + 'French' + ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'unsupported_model', $result->get_error_code(), 'Error code should be unsupported_model' ); + } +} diff --git a/tests/Integration/Includes/Experiments/Content_Translation/Content_TranslationTest.php b/tests/Integration/Includes/Experiments/Content_Translation/Content_TranslationTest.php new file mode 100644 index 000000000..3df7ae5d0 --- /dev/null +++ b/tests/Integration/Includes/Experiments/Content_Translation/Content_TranslationTest.php @@ -0,0 +1,243 @@ + 'test-api-key' ) + ); + + // Mock has_valid_ai_credentials to return true for tests. + add_filter( 'wpai_pre_has_valid_credentials_check', '__return_true' ); + + // Enable experiments globally and individually. + update_option( 'wpai_features_enabled', true ); + update_option( 'wpai_feature_content-translation_enabled', true ); + + $registry = new Registry(); + $loader = new Loader( $registry ); + $loader->init(); + + $experiment = $registry->get_feature( 'content-translation' ); + $this->assertInstanceOf( + Content_Translation::class, + $experiment, + 'Content Translation experiment should be registered in the registry.' + ); + } + + /** + * Tear down test case. + * + * @since x.x.x + */ + public function tearDown(): void { + wp_set_current_user( 0 ); + delete_option( 'wpai_features_enabled' ); + delete_option( 'wpai_feature_content-translation_enabled' ); + delete_option( 'wp_ai_client_provider_credentials' ); + remove_filter( 'wpai_pre_has_valid_credentials_check', '__return_true' ); + parent::tearDown(); + } + + /** + * Test that the experiment is registered correctly. + * + * @since x.x.x + */ + public function test_experiment_registration(): void { + $experiment = new Content_Translation(); + + $this->assertEquals( 'content-translation', $experiment->get_id() ); + $this->assertEquals( 'Content Translation', $experiment->get_label() ); + $this->assertEquals( + Experiment_Category::EDITOR, + $experiment->get_category() + ); + $this->assertTrue( $experiment->is_enabled() ); + } + + /** + * Test that the experiment can be disabled via filter. + * + * @since x.x.x + */ + public function test_experiment_can_be_disabled(): void { + add_filter( 'wpai_feature_content-translation_enabled', '__return_false' ); + + $experiment = new Content_Translation(); + $this->assertFalse( $experiment->is_enabled() ); + + remove_filter( 'wpai_feature_content-translation_enabled', '__return_false' ); + } + + /** + * Test that the experiment metadata is correct. + * + * @since x.x.x + */ + public function test_experiment_metadata(): void { + $experiment = new Content_Translation(); + + $this->assertEquals( 'content-translation', $experiment->get_id() ); + $this->assertNotEmpty( + $experiment->get_label(), + 'Label should not be empty' + ); + $this->assertNotEmpty( + $experiment->get_description(), + 'Description should not be empty' + ); + } + + /** + * Test that register() hooks all required actions. + * + * @since x.x.x + */ + public function test_register_hooks_actions(): void { + $experiment = new Content_Translation(); + $experiment->register(); + + $this->assertNotFalse( + has_action( + 'wp_abilities_api_init', + array( $experiment, 'register_abilities' ) + ), + 'register_abilities should be hooked to wp_abilities_api_init' + ); + $this->assertNotFalse( + has_action( + 'admin_enqueue_scripts', + array( $experiment, 'enqueue_assets' ) + ), + 'enqueue_assets should be hooked to admin_enqueue_scripts' + ); + $this->assertNotFalse( + has_action( + 'enqueue_block_assets', + array( $experiment, 'enqueue_block_assets' ) + ), + 'enqueue_block_assets should be hooked to enqueue_block_assets' + ); + } + + /** + * Tests that register_abilities() registers the ai/content-translation ability. + * + * @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' ); + + $ability = wp_get_ability( 'ai/content-translation' ); + $this->assertNotNull( + $ability, + 'ai/content-translation ability should be registered' + ); + } + + /** + * Test that enqueue_assets() does not enqueue the assets on the wrong admin page. + * + * @since x.x.x + */ + public function test_enqueue_assets_does_not_enqueue_on_wrong_admin_page(): void { + $experiment = new Content_Translation(); + $experiment->register(); + $experiment->enqueue_assets( 'edit.php' ); + + $this->assertFalse( + wp_script_is( 'ai_content_translation', 'enqueued' ), + 'Script should not be enqueued on edit.php' + ); + } + + /** + * Test that the experiment is disabled when the global toggle is off. + * + * @since x.x.x + */ + public function test_experiment_disabled_when_global_toggle_off(): void { + update_option( 'wpai_features_enabled', false ); + + $experiment = new Content_Translation(); + $this->assertFalse( + $experiment->is_enabled(), + 'Experiment should be disabled when global toggle is off' + ); + } + + /** + * Test that enqueue_assets() localizes the default minimum content length. + * + * @since x.x.x + */ + public function test_enqueue_assets_localizes_default_min_content_length(): void { + $experiment = new Content_Translation(); + $experiment->enqueue_assets( 'post.php' ); + + $this->assertTrue( + wp_script_is( 'ai_content_translation', 'enqueued' ) + ); + $this->assertStringContainsString( + '"minContentLength":"15"', + (string) wp_scripts()->get_data( 'ai_content_translation', 'data' ), + 'Data should contain the default minimum content length' + ); + } + + /** + * Test that enqueue_assets() localizes the filtered minimum content length. + * + * @since x.x.x + */ + public function test_enqueue_assets_localizes_filtered_min_content_length(): void { + $filter = static function () { + return 250; + }; + + add_filter( 'wpai_min_content_length', $filter ); + + $experiment = new Content_Translation(); + $experiment->enqueue_assets( 'post.php' ); + + remove_filter( 'wpai_min_content_length', $filter ); + + $this->assertStringContainsString( + '"minContentLength":"250"', + (string) wp_scripts()->get_data( 'ai_content_translation', 'data' ), + 'Data should contain the filtered minimum content length' + ); + } +} diff --git a/tests/e2e/specs/experiments/content-translation.spec.js b/tests/e2e/specs/experiments/content-translation.spec.js new file mode 100644 index 000000000..5738db586 --- /dev/null +++ b/tests/e2e/specs/experiments/content-translation.spec.js @@ -0,0 +1,206 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +/** + * Internal dependencies + */ +const { + enableExperiment, + enableExperiments, + disableExperiments, + disableExperiment, +} = require( '../../utils/helpers' ); + +const MOCKED_RESPONSE = + 'Edit or Delete Your First WordPress Post to Begin Your Blogging Adventure'; + +test.describe( 'Content Translation Experiment', () => { + test( 'Can enable the content translation experiment', async ( { + admin, + page, + } ) => { + // Globally turn on Experiments. + await enableExperiments( admin, page ); + + // Enable the Content Translation Experiment. + await enableExperiment( admin, page, 'Content Translation' ); + } ); + + test( 'Can use the Content Translation Experiment', async ( { + admin, + editor, + page, + } ) => { + // Globally turn on Experiments. + await enableExperiments( admin, page ); + + // Enable the Content Translation Experiment. + await enableExperiment( admin, page, 'Content Translation' ); + + await admin.createNewPost( { + postType: 'post', + title: 'Test Content Translation Experiment', + } ); + + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { + content: + 'This is some test content for the Content Translation Experiment. It needs to have enough words to meet the minimum content length requirement for translation to be enabled. The translation feature requires a substantial amount of text before it will allow the user to generate a translation of the post content. This ensures that the generated translation is meaningful and provides value to readers who want to read the post content in a different language. Adding more words here to make sure we exceed the minimum threshold that is configured for this experiment in the plugin settings and server side filters.', + }, + } ); + + // Save the post. + await editor.saveDraft(); + + // Ensure the sidebar is visible. + await editor.openDocumentSettingsSidebar(); + + // Switch to the Post tab (if not already on it). + await page.getByRole( 'tab', { name: 'Post' } ).click(); + + // Ensure the Generate Translation button exists, is visible, and has the correct text. + const generateButton = page.getByRole( 'button', { + name: 'Generate Translation', + } ); + + await expect( generateButton ).toBeVisible(); + + // Initiate the translation process. + await generateButton.click(); + + // Fill up the modal with the required information. + await page.getByLabel( 'Translate to' ).selectOption( { + label: 'French', + } ); + + await page.getByLabel( 'Also translate the title' ).check(); + + // Click the Translate button. + await page.getByRole( 'button', { name: 'Translate' } ).click(); + + // Ensure the generated translation is replaced at both the post title, and the first paragraph. + await expect( + editor.canvas.getByRole( 'textbox', { name: 'Add title' } ) + ).toHaveText( MOCKED_RESPONSE ); + + await expect( + editor.canvas + .getByRole( 'document', { name: 'Block: Paragraph' } ) + .first() + ).toHaveText( MOCKED_RESPONSE ); + + // Save the post. + await editor.saveDraft(); + } ); + + test( 'Ensure the Content Translation UI is not visible when Experiments are globally disabled', async ( { + admin, + editor, + page, + } ) => { + // Globally turn on Experiments. + await enableExperiments( admin, page ); + + // Enable the Content Translation Experiment. + await enableExperiment( admin, page, 'Content Translation' ); + + // Globally turn off Experiments. + await disableExperiments( admin, page ); + + // Create a new post. + await admin.createNewPost( { + postType: 'post', + title: 'Test Content Translation Experiment Globally Disabled', + content: + 'This is some test content for the Content Translation Experiment.', + } ); + + // Save the post. + await editor.saveDraft(); + + // Ensure the sidebar is visible. + await editor.openDocumentSettingsSidebar(); + + // Ensure the Generate Translation button doesn't exist. + await expect( + page.getByRole( 'button', { + name: 'Generate Translation', + } ) + ).not.toBeVisible(); + } ); + + test( 'Translation button is disabled when content is shorter than the minimum length', async ( { + admin, + editor, + page, + } ) => { + // Globally turn on Experiments. + await enableExperiments( admin, page ); + + // Enable the Content Translation Experiment. + await enableExperiment( admin, page, 'Content Translation' ); + + // Create a new post with content shorter than the minimum length. + await admin.createNewPost( { + postType: 'post', + title: 'Test Short Content', + } ); + + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { + content: 'Too short.', + }, + } ); + + // Save the post. + await editor.saveDraft(); + + // Ensure the sidebar is visible. + await editor.openDocumentSettingsSidebar(); + + // Switch to the Post tab (if not already on it). + await page.getByRole( 'tab', { name: 'Post' } ).click(); + + // Ensure the Generate Translation button is visible but disabled. + const generateButton = page.getByRole( 'button', { + name: 'Generate Translation', + } ); + await expect( generateButton ).toBeVisible(); + await expect( generateButton ).toBeDisabled(); + } ); + + test( 'Ensure the Content Translation Experiment UI is not visible when the experiment is disabled', async ( { + admin, + editor, + page, + } ) => { + // Globally turn on Experiments. + await enableExperiments( admin, page ); + + // Disable the Content Translation Experiment. + await disableExperiment( admin, page, 'Content Translation' ); + + // Create a new post. + await admin.createNewPost( { + postType: 'post', + title: 'Test Content Translation Experiment Disabled', + content: + 'This is some test content for the Content Translation Experiment.', + } ); + + // Save the post. + await editor.saveDraft(); + + // Ensure the sidebar is visible. + await editor.openDocumentSettingsSidebar(); + + // Ensure the Generate Translation button doesn't exist. + await expect( + page.getByRole( 'button', { name: 'Generate Translation' } ) + ).toHaveCount( 0 ); + } ); +} ); diff --git a/webpack.config.js b/webpack.config.js index 454133270..7882107c4 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -120,6 +120,11 @@ module.exports = { 'src/experiments/alt-text-generation', 'bulk.ts' ), + 'experiments/content-translation': path.resolve( + process.cwd(), + 'src/experiments/content-translation', + 'index.tsx' + ) }, plugins: [