*/ const ALLOWED_FIELDS = array( 'title', 'content', 'excerpt' ); /** * Settings handler. * * @since 1.0.0 * @var AI_Translator_Settings */ private $settings; /** * Translator handler. * * @since 1.0.0 * @var AI_Translator_Translator */ private $translator; /** * Constructor. * * @since 1.0.0 * * @param AI_Translator_Settings $settings Settings handler. * @param AI_Translator_Translator $translator Translator handler. */ public function __construct( AI_Translator_Settings $settings, AI_Translator_Translator $translator ) { $this->settings = $settings; $this->translator = $translator; } /** * Registers WordPress hooks. * * @since 1.0.0 * * @return void */ public function register() { add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) ); add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_block_editor_assets' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_classic_editor_assets' ) ); add_action( 'add_meta_boxes', array( $this, 'register_classic_meta_box' ) ); } /** * Registers the REST API routes. * * @since 1.0.0 * * @return void */ public function register_rest_routes() { register_rest_route( AI_TRANSLATOR_REST_NAMESPACE, '/translate', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'handle_translate_request' ), 'permission_callback' => array( $this, 'check_translate_permissions' ), 'args' => array( 'post_id' => array( 'type' => 'integer', 'required' => true, 'minimum' => 1, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ), 'fields' => array( 'type' => 'array', 'required' => true, 'minItems' => 1, 'uniqueItems' => true, 'items' => array( 'type' => 'string', 'enum' => self::ALLOWED_FIELDS, ), 'sanitize_callback' => 'rest_sanitize_request_arg', 'validate_callback' => 'rest_validate_request_arg', ), 'target_locale' => array( 'type' => 'string', 'required' => true, 'pattern' => '^[A-Za-z]{2,3}(_[A-Za-z0-9]{2,8})?$', 'maxLength' => 20, 'sanitize_callback' => array( $this, 'sanitize_locale' ), 'validate_callback' => 'rest_validate_request_arg', ), ), ) ); register_rest_route( AI_TRANSLATOR_REST_NAMESPACE, '/translate-text', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'handle_translate_text_request' ), 'permission_callback' => array( $this, 'check_translate_permissions' ), 'args' => array( 'post_id' => array( 'type' => 'integer', 'required' => true, 'minimum' => 1, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ), 'text' => array( 'type' => 'string', 'required' => true, 'minLength' => 1, 'maxLength' => 20000, 'sanitize_callback' => array( $this, 'sanitize_raw_text' ), 'validate_callback' => 'rest_validate_request_arg', ), 'target_locale' => array( 'type' => 'string', 'required' => true, 'pattern' => '^[A-Za-z]{2,3}(_[A-Za-z0-9]{2,8})?$', 'maxLength' => 20, 'sanitize_callback' => array( $this, 'sanitize_locale' ), 'validate_callback' => 'rest_validate_request_arg', ), ), ) ); } /** * Sanitises a locale string. * * @since 1.0.0 * * @param mixed $value Raw value. * * @return string */ public function sanitize_locale( $value ) { $value = is_string( $value ) ? $value : ''; $value = preg_replace( '/[^A-Za-z0-9_\-]/', '', $value ); if ( ! is_string( $value ) ) { return ''; } return substr( $value, 0, 20 ); } /** * Sanitises a raw text value without stripping HTML. * * The /translate-text endpoint accepts HTML content (Gutenberg block markup, * Classic editor HTML, etc.). Standard WordPress text sanitisers strip HTML * tags; this method only casts to string and preserves markup intact. * Security is enforced by the permission_callback (requires edit_post capability). * * @since 1.2.0 * * @param mixed $value Raw value from the request. * * @return string */ public function sanitize_raw_text( $value ) { return is_string( $value ) ? $value : ''; } /** * REST callback for the translate-text endpoint. * * Translates a raw text string (HTML or plain text) into the target locale. * The post_id parameter is used solely for authorisation; no post content is * read from the database. This endpoint is designed for client-side chunked * translation where the caller supplies the text directly. * * @since 1.2.0 * * @param WP_REST_Request $request The request object. * * @return WP_REST_Response|WP_Error */ public function handle_translate_text_request( WP_REST_Request $request ) { $post_id_param = $request->get_param( 'post_id' ); $text_param = $request->get_param( 'text' ); $target_locale_param = $request->get_param( 'target_locale' ); $post_id = is_numeric( $post_id_param ) ? (int) $post_id_param : 0; $text = is_string( $text_param ) ? $text_param : ''; $target_locale = is_string( $target_locale_param ) ? $target_locale_param : ''; if ( '' === trim( $text ) ) { return new WP_REST_Response( array( 'text' => '' ), 200 ); } // Verify the post exists (post_id is already authorised by the permission callback). $post = get_post( $post_id ); if ( ! $post instanceof WP_Post ) { return new WP_Error( 'ai_translator_post_not_found', __( 'Post not found.', 'robotstxt-ai-translator' ), array( 'status' => 404 ) ); } // Validate the locale is installed on this site. $installed = $this->translator->get_installed_locales(); if ( ! in_array( $target_locale, $installed, true ) ) { return new WP_Error( 'ai_translator_invalid_locale', __( 'The selected language is not installed on this site.', 'robotstxt-ai-translator' ), array( 'status' => 400 ) ); } $translated = $this->translator->translate( $text, $target_locale ); if ( is_wp_error( $translated ) ) { return $this->error_with_status( $translated, 502 ); } return new WP_REST_Response( array( 'text' => $translated ), 200 ); } /** * REST permission callback for the translate endpoint. * * @since 1.0.0 * * @param WP_REST_Request $request The request object. * * @return bool|WP_Error True if allowed, WP_Error otherwise. */ public function check_translate_permissions( WP_REST_Request $request ) { $post_id_param = $request->get_param( 'post_id' ); $post_id = is_numeric( $post_id_param ) ? (int) $post_id_param : 0; if ( $post_id <= 0 || ! current_user_can( 'edit_post', $post_id ) ) { return new WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed to edit this post.', 'robotstxt-ai-translator' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * REST callback for the translate endpoint. * * @since 1.0.0 * * @param WP_REST_Request $request The request object. * * @return WP_REST_Response|WP_Error */ public function handle_translate_request( WP_REST_Request $request ) { $post_id_param = $request->get_param( 'post_id' ); $target_locale_param = $request->get_param( 'target_locale' ); $post_id = is_numeric( $post_id_param ) ? (int) $post_id_param : 0; $fields = (array) $request->get_param( 'fields' ); $target_locale = is_string( $target_locale_param ) ? $target_locale_param : ''; $post = get_post( $post_id ); if ( ! $post instanceof WP_Post ) { return new WP_Error( 'ai_translator_post_not_found', __( 'Post not found.', 'robotstxt-ai-translator' ), array( 'status' => 404 ) ); } $installed = $this->translator->get_installed_locales(); if ( ! in_array( $target_locale, $installed, true ) ) { return new WP_Error( 'ai_translator_invalid_locale', __( 'The selected language is not installed on this site.', 'robotstxt-ai-translator' ), array( 'status' => 400 ) ); } $site_settings = $this->settings->get_settings(); $allowed = array(); if ( in_array( 'title', $fields, true ) && ! empty( $site_settings['translate_title'] ) ) { $allowed[] = 'title'; } if ( in_array( 'content', $fields, true ) && ! empty( $site_settings['translate_content'] ) ) { $allowed[] = 'content'; } if ( in_array( 'excerpt', $fields, true ) && ! empty( $site_settings['translate_excerpt'] ) && post_type_supports( $post->post_type, 'excerpt' ) ) { $allowed[] = 'excerpt'; } if ( empty( $allowed ) ) { return new WP_Error( 'ai_translator_no_fields', __( 'No translatable fields were selected, or the requested fields are disabled in the settings.', 'robotstxt-ai-translator' ), array( 'status' => 400 ) ); } $response = array(); if ( in_array( 'title', $allowed, true ) ) { $translated = $this->translator->translate( $post->post_title, $target_locale ); if ( is_wp_error( $translated ) ) { return $this->error_with_status( $translated, 502 ); } $response['title'] = $translated; } if ( in_array( 'content', $allowed, true ) ) { $translated = $this->translator->translate( $post->post_content, $target_locale ); if ( is_wp_error( $translated ) ) { return $this->error_with_status( $translated, 502 ); } $response['content'] = $translated; } if ( in_array( 'excerpt', $allowed, true ) ) { $translated = $this->translator->translate( $post->post_excerpt, $target_locale ); if ( is_wp_error( $translated ) ) { return $this->error_with_status( $translated, 502 ); } $response['excerpt'] = $translated; } return new WP_REST_Response( $response, 200 ); } /** * Attaches an HTTP status to a WP_Error without overwriting existing data. * * @since 1.0.0 * * @param WP_Error $error The original error. * @param int $status HTTP status code to add. * * @return WP_Error */ private function error_with_status( WP_Error $error, $status ) { $existing = (array) $error->get_error_data(); $error->add_data( array_merge( $existing, array( 'status' => (int) $status ) ) ); return $error; } /** * Registers the Classic editor metabox. * * @since 1.0.0 * * @return void */ public function register_classic_meta_box() { $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; // The block editor already shows the AI Translator via the PluginSidebar // (translation icon in the top-right). Skipping metabox registration there // avoids duplicating the UI in the post settings panel. if ( $screen && method_exists( $screen, 'is_block_editor' ) && $screen->is_block_editor() ) { return; } $post_types = $this->get_supported_post_types(); foreach ( $post_types as $post_type ) { add_meta_box( 'ai-translator', __( 'AI Translator', 'robotstxt-ai-translator' ), array( $this, 'render_classic_meta_box' ), $post_type, 'side', 'default' ); } } /** * Renders the Classic editor metabox content. * * @since 1.0.0 * * @param WP_Post $post The current post. * * @return void */ public function render_classic_meta_box( $post ) { $languages = $this->get_language_list(); $settings = $this->settings->get_settings(); ?>
translator->is_available() ) : ?>

is_block_editor() ) { return; } $this->enqueue_assets( 'classic' ); } /** * Enqueues assets for the Block editor. * * @since 1.0.0 * * @return void */ public function enqueue_block_editor_assets() { $this->enqueue_assets( 'block' ); } /** * Common asset enqueue logic for both editors. * * @since 1.0.0 * * @param string $context Either 'classic' or 'block'. * * @return void */ private function enqueue_assets( $context ) { $dependencies = array( 'wp-api-fetch', 'wp-i18n' ); if ( 'block' === $context ) { $dependencies = array_merge( $dependencies, array( 'wp-plugins', 'wp-editor', 'wp-element', 'wp-components', 'wp-data', 'wp-blocks', ) ); } $asset_ver = AI_TRANSLATOR_VERSION . '.' . (string) filemtime( AI_TRANSLATOR_DIR . 'assets/js/editor.js' ); wp_register_script( 'ai-translator-editor', AI_TRANSLATOR_URL . 'assets/js/editor.js', $dependencies, $asset_ver, true ); wp_register_style( 'ai-translator-editor', AI_TRANSLATOR_URL . 'assets/css/editor.css', array(), $asset_ver ); $post_id = $this->resolve_current_post_id(); $settings = $this->settings->get_settings(); $data = array( 'restNamespace' => AI_TRANSLATOR_REST_NAMESPACE, 'restRoute' => '/translate', 'chunkTextRoute' => '/translate-text', 'chunkSize' => 5000, 'context' => $context, 'postId' => $post_id, 'available' => $this->translator->is_available(), 'languages' => $this->get_language_list(), 'settings' => array( 'translateTitle' => ! empty( $settings['translate_title'] ), 'translateContent' => ! empty( $settings['translate_content'] ), 'translateExcerpt' => ! empty( $settings['translate_excerpt'] ), ), 'i18n' => array( 'translate' => __( 'Translate', 'robotstxt-ai-translator' ), 'translating' => __( 'Translating…', 'robotstxt-ai-translator' ), /* translators: 1: number of chunks translated so far, 2: total number of chunks. */ 'translatingProgress' => __( 'Translating… ({done}/{total})', 'robotstxt-ai-translator' ), 'targetLanguage' => __( 'Target language', 'robotstxt-ai-translator' ), 'panelTitle' => __( 'AI Translator', 'robotstxt-ai-translator' ), 'success' => __( 'Translation applied. Review and save the post.', 'robotstxt-ai-translator' ), 'genericError' => __( 'Translation failed.', 'robotstxt-ai-translator' ), 'noLanguages' => __( 'No languages are installed on this site.', 'robotstxt-ai-translator' ), 'noFields' => __( 'No translation fields are enabled in settings.', 'robotstxt-ai-translator' ), 'aiUnavailable' => __( 'AI features are disabled for this WordPress installation.', 'robotstxt-ai-translator' ), 'saveBeforeWarn' => __( 'You have unsaved changes. Save the post before translating to ensure the latest content is used.', 'robotstxt-ai-translator' ), 'translateTitle' => __( 'Translate title', 'robotstxt-ai-translator' ), 'translateContent' => __( 'Translate content', 'robotstxt-ai-translator' ), 'translateExcerpt' => __( 'Translate excerpt', 'robotstxt-ai-translator' ), ), ); wp_add_inline_script( 'ai-translator-editor', 'window.aiTranslatorData = ' . wp_json_encode( $data ) . ';', 'before' ); wp_enqueue_script( 'ai-translator-editor' ); wp_enqueue_style( 'ai-translator-editor' ); } /** * Resolves the current post ID when enqueueing assets. * * @since 1.0.0 * * @return int Post ID, or 0 when it cannot be determined. */ private function resolve_current_post_id() { $post = get_post(); if ( $post instanceof WP_Post ) { return (int) $post->ID; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( isset( $_GET['post'] ) && is_scalar( $_GET['post'] ) ) { return absint( (string) $_GET['post'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended } return 0; } /** * Returns the post types where the metabox should appear. * * @since 1.0.0 * * @return array */ private function get_supported_post_types() { $post_types = get_post_types( array( 'public' => true ), 'names' ); if ( ! is_array( $post_types ) ) { return array(); } unset( $post_types['attachment'] ); /** * Filters the post types where the AI Translator metabox is registered. * * @since 1.0.0 * * @param array $post_types List of post type names. */ return (array) apply_filters( 'ai_translator_supported_post_types', array_values( $post_types ) ); } /** * Returns the list of languages available for translation on this site. * * Includes en_US plus any locales whose .mo files are installed. * * @since 1.0.0 * * @return array */ private function get_language_list() { $locales = $this->translator->get_installed_locales(); if ( ! function_exists( 'wp_get_available_translations' ) ) { require_once ABSPATH . 'wp-admin/includes/translation-install.php'; } $translations = function_exists( 'wp_get_available_translations' ) ? wp_get_available_translations() : array(); $languages = array(); foreach ( $locales as $locale ) { if ( 'en_US' === $locale ) { $name = 'English (United States)'; } elseif ( isset( $translations[ $locale ]['native_name'] ) && '' !== $translations[ $locale ]['native_name'] ) { $name = (string) $translations[ $locale ]['native_name']; } elseif ( isset( $translations[ $locale ]['english_name'] ) && '' !== $translations[ $locale ]['english_name'] ) { $name = (string) $translations[ $locale ]['english_name']; } else { $name = $locale; } $languages[] = array( 'locale' => $locale, 'name' => $name, ); } usort( $languages, static function ( $a, $b ) { return strcasecmp( $a['name'], $b['name'] ); } ); return $languages; } } }