robotstxt-ai-translator/includes/class-ai-translator-editor-ui.php
2026-05-23 10:13:56 +00:00

549 lines
16 KiB
PHP

<?php
/**
* Editor UI for the AI Translator plugin.
*
* Registers the REST endpoint, the Classic editor metabox, and the Block editor
* sidebar that lets editors translate the current post.
*
* @package ROBOTSTXT\AI_Translator
* @since 1.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'AI_Translator_Editor_UI' ) ) {
/**
* Editor UI registration and REST endpoint handler.
*
* @since 1.0.0
*/
class AI_Translator_Editor_UI {
/**
* Allowed field names that can be translated through the REST endpoint.
*
* @since 1.0.0
* @var array<int,string>
*/
const ALLOWED_FIELDS = array( 'title', 'content' );
/**
* 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',
),
),
)
);
}
/**
* 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 );
}
/**
* 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 ( 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;
}
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();
?>
<div id="ai-translator-classic" class="ai-translator-classic" data-post-id="<?php echo esc_attr( (string) $post->ID ); ?>">
<?php if ( ! $this->translator->is_available() ) : ?>
<p><?php echo esc_html__( 'The WordPress AI plugin is not active. Translation is unavailable.', 'robotstxt-ai-translator' ); ?></p>
<?php elseif ( empty( $settings['translate_title'] ) && empty( $settings['translate_content'] ) ) : ?>
<p><?php echo esc_html__( 'No translation fields are enabled in settings.', 'robotstxt-ai-translator' ); ?></p>
<?php elseif ( empty( $languages ) ) : ?>
<p><?php echo esc_html__( 'No languages are installed on this site.', 'robotstxt-ai-translator' ); ?></p>
<?php else : ?>
<p>
<label for="ai-translator-locale"><?php echo esc_html__( 'Target language', 'robotstxt-ai-translator' ); ?></label>
<select id="ai-translator-locale" class="widefat">
<?php foreach ( $languages as $language ) : ?>
<option value="<?php echo esc_attr( $language['locale'] ); ?>"><?php echo esc_html( $language['name'] ); ?></option>
<?php endforeach; ?>
</select>
</p>
<p>
<button type="button" class="button button-primary" id="ai-translator-classic-button">
<?php echo esc_html__( 'Translate', 'robotstxt-ai-translator' ); ?>
</button>
</p>
<p class="description">
<?php echo esc_html__( 'Save the post before translating to ensure the latest content is used. The translation replaces the current title and/or content.', 'robotstxt-ai-translator' ); ?>
</p>
<div id="ai-translator-classic-status" class="ai-translator-status" role="status" aria-live="polite"></div>
<?php endif; ?>
</div>
<?php
}
/**
* Enqueues assets for the Classic editor.
*
* @since 1.0.0
*
* @param string $hook Current admin page hook.
*
* @return void
*/
public function enqueue_classic_editor_assets( $hook ) {
if ( ! in_array( $hook, array( 'post.php', 'post-new.php' ), true ) ) {
return;
}
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
if ( $screen && method_exists( $screen, 'is_block_editor' ) && $screen->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',
'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'] ),
),
'i18n' => array(
'translate' => __( 'Translate', 'robotstxt-ai-translator' ),
'translating' => __( 'Translating…', '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' => __( 'The WordPress AI plugin is not active.', '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' ),
),
);
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;
}
if ( isset( $_GET['post'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$post_get = wp_unslash( $_GET['post'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
return is_scalar( $post_get ) ? absint( $post_get ) : 0;
}
return 0;
}
/**
* Returns the post types where the metabox should appear.
*
* @since 1.0.0
*
* @return array<int,string>
*/
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<int,string> $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<int,array{locale:string,name:string}>
*/
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;
}
}
}