563 lines
17 KiB
PHP
563 lines
17 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', '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',
|
|
),
|
|
),
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 ( 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();
|
|
|
|
?>
|
|
<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__( 'AI features are disabled for this WordPress installation.', 'robotstxt-ai-translator' ); ?></p>
|
|
<?php elseif ( empty( $settings['translate_title'] ) && empty( $settings['translate_content'] ) && empty( $settings['translate_excerpt'] ) ) : ?>
|
|
<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'] ),
|
|
'translateExcerpt' => ! empty( $settings['translate_excerpt'] ),
|
|
),
|
|
'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' => __( '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<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;
|
|
}
|
|
}
|
|
}
|