v1.0.0
This commit is contained in:
commit
a157afe625
21 changed files with 4623 additions and 0 deletions
530
includes/class-ai-translator-admin.php
Normal file
530
includes/class-ai-translator-admin.php
Normal file
|
|
@ -0,0 +1,530 @@
|
|||
<?php
|
||||
/**
|
||||
* Admin pages and form handling for the AI Translator plugin.
|
||||
*
|
||||
* @package ROBOTSTXT\AI_Translator
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! class_exists( 'AI_Translator_Admin' ) ) {
|
||||
|
||||
/**
|
||||
* Registers the settings pages and handles form submissions.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class AI_Translator_Admin {
|
||||
|
||||
/**
|
||||
* Menu slug used in single-site and Multisite per-site admin.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @var string
|
||||
*/
|
||||
const SITE_MENU_SLUG = 'ai-translator-settings';
|
||||
|
||||
/**
|
||||
* Menu slug used in Multisite network admin.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @var string
|
||||
*/
|
||||
const NETWORK_MENU_SLUG = 'ai-translator-network-settings';
|
||||
|
||||
/**
|
||||
* Nonce action for the site form.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @var string
|
||||
*/
|
||||
const NONCE_SITE = 'ai_translator_site_save';
|
||||
|
||||
/**
|
||||
* Nonce action for the network form.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @var string
|
||||
*/
|
||||
const NONCE_NETWORK = 'ai_translator_network_save';
|
||||
|
||||
/**
|
||||
* Settings handler.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @var AI_Translator_Settings
|
||||
*/
|
||||
private $settings;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param AI_Translator_Settings $settings Settings handler.
|
||||
*/
|
||||
public function __construct( AI_Translator_Settings $settings ) {
|
||||
$this->settings = $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers WordPress hooks.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register() {
|
||||
add_action( 'admin_menu', array( $this, 'register_site_menu' ) );
|
||||
add_action( 'admin_post_ai_translator_save_site', array( $this, 'handle_site_save' ) );
|
||||
|
||||
if ( is_multisite() ) {
|
||||
add_action( 'network_admin_menu', array( $this, 'register_network_menu' ) );
|
||||
add_action( 'network_admin_edit_ai_translator_save_network', array( $this, 'handle_network_save' ) );
|
||||
}
|
||||
|
||||
add_filter( 'plugin_action_links_' . AI_TRANSLATOR_BASENAME, array( $this, 'add_settings_link' ) );
|
||||
|
||||
if ( is_multisite() ) {
|
||||
add_filter( 'network_admin_plugin_action_links_' . AI_TRANSLATOR_BASENAME, array( $this, 'add_network_settings_link' ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the site-level settings page.
|
||||
*
|
||||
* Hidden in Multisite when the network mode is 'global', because the global
|
||||
* settings live in the network admin in that case.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_site_menu() {
|
||||
if ( is_multisite() && 'global' === $this->settings->get_network_mode() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
add_options_page(
|
||||
__( 'AI Translator', 'robotstxt-ai-translator' ),
|
||||
__( 'AI Translator', 'robotstxt-ai-translator' ),
|
||||
'manage_options',
|
||||
self::SITE_MENU_SLUG,
|
||||
array( $this, 'render_site_page' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the network-level settings page.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_network_menu() {
|
||||
add_submenu_page(
|
||||
'settings.php',
|
||||
__( 'AI Translator', 'robotstxt-ai-translator' ),
|
||||
__( 'AI Translator', 'robotstxt-ai-translator' ),
|
||||
'manage_network_options',
|
||||
self::NETWORK_MENU_SLUG,
|
||||
array( $this, 'render_network_page' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a settings link in the site plugin list.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param array<int,string> $links Existing action links.
|
||||
*
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public function add_settings_link( $links ) {
|
||||
if ( is_multisite() && 'global' === $this->settings->get_network_mode() ) {
|
||||
return $links;
|
||||
}
|
||||
|
||||
$url = admin_url( 'options-general.php?page=' . self::SITE_MENU_SLUG );
|
||||
$label = esc_html__( 'Settings', 'robotstxt-ai-translator' );
|
||||
|
||||
array_unshift( $links, '<a href="' . esc_url( $url ) . '">' . $label . '</a>' );
|
||||
|
||||
return $links;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a settings link in the network plugin list.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param array<int,string> $links Existing action links.
|
||||
*
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public function add_network_settings_link( $links ) {
|
||||
$url = network_admin_url( 'settings.php?page=' . self::NETWORK_MENU_SLUG );
|
||||
$label = esc_html__( 'Settings', 'robotstxt-ai-translator' );
|
||||
|
||||
array_unshift( $links, '<a href="' . esc_url( $url ) . '">' . $label . '</a>' );
|
||||
|
||||
return $links;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the site-level settings page.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render_site_page() {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_die( esc_html__( 'You do not have permission to access this page.', 'robotstxt-ai-translator' ) );
|
||||
}
|
||||
|
||||
$current = $this->settings->get_site_settings();
|
||||
$defaults = is_multisite() ? $this->settings->get_network_settings() : array();
|
||||
$updated = 1 === filter_input( INPUT_GET, 'updated', FILTER_VALIDATE_INT );
|
||||
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php echo esc_html__( 'AI Translator Settings', 'robotstxt-ai-translator' ); ?></h1>
|
||||
|
||||
<?php if ( $updated ) : ?>
|
||||
<div class="notice notice-success is-dismissible">
|
||||
<p><?php echo esc_html__( 'Settings saved.', 'robotstxt-ai-translator' ); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
|
||||
<input type="hidden" name="action" value="ai_translator_save_site" />
|
||||
<?php wp_nonce_field( self::NONCE_SITE ); ?>
|
||||
|
||||
<table class="form-table" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row"><?php echo esc_html__( 'Translation fields', 'robotstxt-ai-translator' ); ?></th>
|
||||
<td>
|
||||
<fieldset>
|
||||
<legend class="screen-reader-text"><?php echo esc_html__( 'Translation fields', 'robotstxt-ai-translator' ); ?></legend>
|
||||
|
||||
<label for="ai-translator-translate-title">
|
||||
<input type="checkbox" id="ai-translator-translate-title" name="ai_translator_settings[translate_title]" value="1" <?php checked( $current['translate_title'] ); ?> />
|
||||
<?php echo esc_html__( 'Translate title', 'robotstxt-ai-translator' ); ?>
|
||||
</label><br />
|
||||
|
||||
<label for="ai-translator-translate-content">
|
||||
<input type="checkbox" id="ai-translator-translate-content" name="ai_translator_settings[translate_content]" value="1" <?php checked( $current['translate_content'] ); ?> />
|
||||
<?php echo esc_html__( 'Translate content', 'robotstxt-ai-translator' ); ?>
|
||||
</label>
|
||||
|
||||
<?php if ( is_multisite() ) : ?>
|
||||
<p class="description">
|
||||
<?php
|
||||
printf(
|
||||
/* translators: 1: title default, 2: content default. */
|
||||
esc_html__( 'Network defaults: title %1$s, content %2$s.', 'robotstxt-ai-translator' ),
|
||||
isset( $defaults['translate_title'] ) && $defaults['translate_title'] ? esc_html__( 'enabled', 'robotstxt-ai-translator' ) : esc_html__( 'disabled', 'robotstxt-ai-translator' ),
|
||||
isset( $defaults['translate_content'] ) && $defaults['translate_content'] ? esc_html__( 'enabled', 'robotstxt-ai-translator' ) : esc_html__( 'disabled', 'robotstxt-ai-translator' )
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</fieldset>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<?php submit_button(); ?>
|
||||
</form>
|
||||
|
||||
<?php $this->render_model_recommendations(); ?>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the network-level settings page.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render_network_page() {
|
||||
if ( ! current_user_can( 'manage_network_options' ) ) {
|
||||
wp_die( esc_html__( 'You do not have permission to access this page.', 'robotstxt-ai-translator' ) );
|
||||
}
|
||||
|
||||
$mode = $this->settings->get_network_mode();
|
||||
$network = $this->settings->get_network_settings();
|
||||
$updated = 1 === filter_input( INPUT_GET, 'updated', FILTER_VALIDATE_INT );
|
||||
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php echo esc_html__( 'AI Translator Network Settings', 'robotstxt-ai-translator' ); ?></h1>
|
||||
|
||||
<?php if ( $updated ) : ?>
|
||||
<div class="notice notice-success is-dismissible">
|
||||
<p><?php echo esc_html__( 'Network settings saved.', 'robotstxt-ai-translator' ); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="<?php echo esc_url( network_admin_url( 'edit.php?action=ai_translator_save_network' ) ); ?>">
|
||||
<?php wp_nonce_field( self::NONCE_NETWORK ); ?>
|
||||
|
||||
<table class="form-table" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row"><?php echo esc_html__( 'Configuration mode', 'robotstxt-ai-translator' ); ?></th>
|
||||
<td>
|
||||
<fieldset>
|
||||
<legend class="screen-reader-text"><?php echo esc_html__( 'Configuration mode', 'robotstxt-ai-translator' ); ?></legend>
|
||||
|
||||
<label>
|
||||
<input type="radio" name="ai_translator_mode" value="global" <?php checked( 'global', $mode ); ?> />
|
||||
<?php echo esc_html__( 'Global configuration: a single setting applies to every site.', 'robotstxt-ai-translator' ); ?>
|
||||
</label><br />
|
||||
|
||||
<label>
|
||||
<input type="radio" name="ai_translator_mode" value="per-site" <?php checked( 'per-site', $mode ); ?> />
|
||||
<?php echo esc_html__( 'Per-site configuration: each site can override these defaults.', 'robotstxt-ai-translator' ); ?>
|
||||
</label>
|
||||
</fieldset>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<?php echo esc_html__( 'Translation fields', 'robotstxt-ai-translator' ); ?>
|
||||
</th>
|
||||
<td>
|
||||
<fieldset>
|
||||
<legend class="screen-reader-text"><?php echo esc_html__( 'Translation fields', 'robotstxt-ai-translator' ); ?></legend>
|
||||
|
||||
<label for="ai-translator-net-translate-title">
|
||||
<input type="checkbox" id="ai-translator-net-translate-title" name="ai_translator_settings[translate_title]" value="1" <?php checked( $network['translate_title'] ); ?> />
|
||||
<?php echo esc_html__( 'Translate title', 'robotstxt-ai-translator' ); ?>
|
||||
</label><br />
|
||||
|
||||
<label for="ai-translator-net-translate-content">
|
||||
<input type="checkbox" id="ai-translator-net-translate-content" name="ai_translator_settings[translate_content]" value="1" <?php checked( $network['translate_content'] ); ?> />
|
||||
<?php echo esc_html__( 'Translate content', 'robotstxt-ai-translator' ); ?>
|
||||
</label>
|
||||
|
||||
<p class="description">
|
||||
<?php echo esc_html__( 'In global mode this is the configuration for the whole network. In per-site mode these values are used as defaults for sites that have not been configured individually.', 'robotstxt-ai-translator' ); ?>
|
||||
</p>
|
||||
</fieldset>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<?php submit_button(); ?>
|
||||
</form>
|
||||
|
||||
<?php $this->render_model_recommendations(); ?>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the site-level form submission.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle_site_save() {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_die( esc_html__( 'You do not have permission to perform this action.', 'robotstxt-ai-translator' ) );
|
||||
}
|
||||
|
||||
check_admin_referer( self::NONCE_SITE );
|
||||
|
||||
$raw = isset( $_POST['ai_translator_settings'] ) && is_array( $_POST['ai_translator_settings'] )
|
||||
? wp_unslash( $_POST['ai_translator_settings'] ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitised by sanitize_settings() below.
|
||||
: array();
|
||||
|
||||
$this->settings->update_site_settings( $this->sanitize_settings( $raw ) );
|
||||
|
||||
$redirect = add_query_arg(
|
||||
array(
|
||||
'page' => self::SITE_MENU_SLUG,
|
||||
'updated' => 1,
|
||||
),
|
||||
admin_url( 'options-general.php' )
|
||||
);
|
||||
|
||||
wp_safe_redirect( $redirect );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the network-level form submission.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle_network_save() {
|
||||
if ( ! current_user_can( 'manage_network_options' ) ) {
|
||||
wp_die( esc_html__( 'You do not have permission to perform this action.', 'robotstxt-ai-translator' ) );
|
||||
}
|
||||
|
||||
check_admin_referer( self::NONCE_NETWORK );
|
||||
|
||||
$mode_raw = filter_input( INPUT_POST, 'ai_translator_mode', FILTER_UNSAFE_RAW );
|
||||
$raw_mode = is_string( $mode_raw ) ? sanitize_key( $mode_raw ) : 'global';
|
||||
$mode = in_array( $raw_mode, array( 'global', 'per-site' ), true ) ? $raw_mode : 'global';
|
||||
|
||||
$this->settings->update_network_mode( $mode );
|
||||
|
||||
$raw = isset( $_POST['ai_translator_settings'] ) && is_array( $_POST['ai_translator_settings'] )
|
||||
? wp_unslash( $_POST['ai_translator_settings'] ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitised by sanitize_settings() below.
|
||||
: array();
|
||||
|
||||
$this->settings->update_network_settings( $this->sanitize_settings( $raw ) );
|
||||
|
||||
$redirect = add_query_arg(
|
||||
array(
|
||||
'page' => self::NETWORK_MENU_SLUG,
|
||||
'updated' => 1,
|
||||
),
|
||||
network_admin_url( 'settings.php' )
|
||||
);
|
||||
|
||||
wp_safe_redirect( $redirect );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the model recommendations table.
|
||||
*
|
||||
* Displayed below both the site and network settings pages as guidance for
|
||||
* choosing a provider in the WordPress AI plugin. Purely informational; the
|
||||
* table is not interactive and stores no data.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_model_recommendations() {
|
||||
$rows = $this->get_model_recommendations();
|
||||
|
||||
?>
|
||||
<h2><?php echo esc_html__( 'Recommended models', 'robotstxt-ai-translator' ); ?></h2>
|
||||
|
||||
<p class="description">
|
||||
<?php echo esc_html__( 'This plugin does not call AI providers directly. Configure your preferred provider in the WordPress AI plugin settings. The table below is a guide to help you pick a model based on your translation use case.', 'robotstxt-ai-translator' ); ?>
|
||||
</p>
|
||||
|
||||
<table class="widefat striped ai-translator-recommendations">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col"><?php echo esc_html__( 'Use case', 'robotstxt-ai-translator' ); ?></th>
|
||||
<th scope="col"><?php echo esc_html__( 'Recommended model', 'robotstxt-ai-translator' ); ?></th>
|
||||
<th scope="col"><?php echo esc_html__( 'Why', 'robotstxt-ai-translator' ); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ( $rows as $row ) : ?>
|
||||
<tr>
|
||||
<th scope="row"><?php echo esc_html( $row['use_case'] ); ?></th>
|
||||
<td><?php echo esc_html( $row['model'] ); ?></td>
|
||||
<td><?php echo esc_html( $row['reason'] ); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p class="description">
|
||||
<?php echo esc_html__( 'These recommendations are based on public benchmarks and community feedback as of the plugin release date. They may change as providers update their models.', 'robotstxt-ai-translator' ); ?>
|
||||
</p>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the model recommendations table data.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return array<int,array{use_case:string,model:string,reason:string}>
|
||||
*/
|
||||
private function get_model_recommendations() {
|
||||
$rows = array(
|
||||
array(
|
||||
'use_case' => __( 'European languages (ES, CA, FR, DE, IT, PT)', 'robotstxt-ai-translator' ),
|
||||
'model' => __( 'DeepL API Pro', 'robotstxt-ai-translator' ),
|
||||
'reason' => __( 'Best fluency and naturalness (92/100 in benchmarks). Formality control and custom glossaries.', 'robotstxt-ai-translator' ),
|
||||
),
|
||||
array(
|
||||
'use_case' => __( 'Asian languages (ZH, JA, KO)', 'robotstxt-ai-translator' ),
|
||||
'model' => __( 'GPT-4o / GPT-5 or Claude Sonnet 4', 'robotstxt-ai-translator' ),
|
||||
'reason' => __( 'Better handling of implicit subjects, honorifics, and cultural references. DeepL falls behind here.', 'robotstxt-ai-translator' ),
|
||||
),
|
||||
array(
|
||||
'use_case' => __( 'Marketing / brand tone', 'robotstxt-ai-translator' ),
|
||||
'model' => __( 'Claude Sonnet 4 / Opus 4', 'robotstxt-ai-translator' ),
|
||||
'reason' => __( 'Better preservation of tone, brand voice, and nuance. Ideal for creative copy.', 'robotstxt-ai-translator' ),
|
||||
),
|
||||
array(
|
||||
'use_case' => __( 'Technical documentation / code', 'robotstxt-ai-translator' ),
|
||||
'model' => __( 'GPT-4o / GPT-5', 'robotstxt-ai-translator' ),
|
||||
'reason' => __( 'Higher accuracy with variables, structured formats, and technical terminology.', 'robotstxt-ai-translator' ),
|
||||
),
|
||||
array(
|
||||
'use_case' => __( 'Long documents (100+ pages)', 'robotstxt-ai-translator' ),
|
||||
'model' => __( 'Gemini 2.5 Pro', 'robotstxt-ai-translator' ),
|
||||
'reason' => __( '1M token context window. Maintains terminological consistency across long texts.', 'robotstxt-ai-translator' ),
|
||||
),
|
||||
array(
|
||||
'use_case' => __( 'High volume / low cost', 'robotstxt-ai-translator' ),
|
||||
'model' => __( 'DeepSeek-V3', 'robotstxt-ai-translator' ),
|
||||
'reason' => __( 'Quality comparable to GPT-5 at ~$0.14/M tokens (20-50x cheaper than Claude/GPT).', 'robotstxt-ai-translator' ),
|
||||
),
|
||||
array(
|
||||
'use_case' => __( 'Rare / indigenous languages', 'robotstxt-ai-translator' ),
|
||||
'model' => __( 'Claude Sonnet or Taskade Translate (multi-model routing)', 'robotstxt-ai-translator' ),
|
||||
'reason' => __( 'Better coverage for uncommon language pairs.', 'robotstxt-ai-translator' ),
|
||||
),
|
||||
array(
|
||||
'use_case' => __( 'Maximum coverage (133+ languages)', 'robotstxt-ai-translator' ),
|
||||
'model' => __( 'Google Cloud Translation', 'robotstxt-ai-translator' ),
|
||||
'reason' => __( 'The most complete option, though with slightly lower quality on European languages.', 'robotstxt-ai-translator' ),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Filters the model recommendations shown in the settings pages.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param array<int,array{use_case:string,model:string,reason:string}> $rows Recommendation rows.
|
||||
*/
|
||||
return (array) apply_filters( 'ai_translator_model_recommendations', $rows );
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitises a raw settings array into a strict boolean schema.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param array<string,mixed> $raw Raw input from the form.
|
||||
*
|
||||
* @return array<string,bool>
|
||||
*/
|
||||
private function sanitize_settings( array $raw ) {
|
||||
return array(
|
||||
'translate_title' => ! empty( $raw['translate_title'] ),
|
||||
'translate_content' => ! empty( $raw['translate_content'] ),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
238
includes/class-ai-translator-cli.php
Normal file
238
includes/class-ai-translator-cli.php
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
<?php
|
||||
/**
|
||||
* WP-CLI commands for the AI Translator plugin.
|
||||
*
|
||||
* @package ROBOTSTXT\AI_Translator
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! class_exists( 'AI_Translator_CLI' ) ) {
|
||||
|
||||
/**
|
||||
* Translate posts from the command line.
|
||||
*
|
||||
* ## EXAMPLES
|
||||
*
|
||||
* # Translate a single post to Spanish.
|
||||
* $ wp ai-translator translate --post-id=42 --locale=es_ES
|
||||
*
|
||||
* # Translate every post of a given type, preview only.
|
||||
* $ wp ai-translator translate --post-type=post --locale=fr_FR --dry-run
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class AI_Translator_CLI {
|
||||
|
||||
/**
|
||||
* 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 the command with WP-CLI.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register() {
|
||||
if ( ! class_exists( 'WP_CLI' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
WP_CLI::add_command( 'ai-translator', $this );
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates one or more posts to a target locale.
|
||||
*
|
||||
* ## OPTIONS
|
||||
*
|
||||
* --locale=<locale>
|
||||
* : WordPress locale to translate to. Must be installed on the site.
|
||||
*
|
||||
* [--post-id=<id>]
|
||||
* : ID of a single post to translate. Mutually exclusive with --post-type.
|
||||
*
|
||||
* [--post-type=<type>]
|
||||
* : Post type to translate in bulk. Defaults to 'post' when --post-id is omitted.
|
||||
*
|
||||
* [--status=<status>]
|
||||
* : Post status filter when running in bulk mode. Default: 'publish'.
|
||||
*
|
||||
* [--limit=<n>]
|
||||
* : Maximum number of posts to process when running in bulk mode. Default: 50.
|
||||
*
|
||||
* [--dry-run]
|
||||
* : Show what would be translated without calling the AI provider and without writing to the database.
|
||||
*
|
||||
* ## EXAMPLES
|
||||
*
|
||||
* wp ai-translator translate --post-id=42 --locale=es_ES
|
||||
* wp ai-translator translate --post-type=page --locale=fr_FR --limit=10 --dry-run
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param array<int,string> $args Positional arguments (unused).
|
||||
* @param array<string,string> $assoc_args Associative arguments.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function translate( $args, $assoc_args ) {
|
||||
unset( $args );
|
||||
|
||||
$locale = isset( $assoc_args['locale'] ) ? (string) $assoc_args['locale'] : '';
|
||||
$dry_run = isset( $assoc_args['dry-run'] );
|
||||
$post_id = isset( $assoc_args['post-id'] ) ? (int) $assoc_args['post-id'] : 0;
|
||||
$type = isset( $assoc_args['post-type'] ) ? (string) $assoc_args['post-type'] : '';
|
||||
$status = isset( $assoc_args['status'] ) ? (string) $assoc_args['status'] : 'publish';
|
||||
$limit = isset( $assoc_args['limit'] ) ? max( 1, (int) $assoc_args['limit'] ) : 50;
|
||||
|
||||
if ( '' === $locale ) {
|
||||
WP_CLI::error( __( 'The --locale flag is required.', 'robotstxt-ai-translator' ) );
|
||||
}
|
||||
|
||||
if ( $post_id > 0 && '' !== $type ) {
|
||||
WP_CLI::error( __( 'The --post-id and --post-type flags are mutually exclusive.', 'robotstxt-ai-translator' ) );
|
||||
}
|
||||
|
||||
$current_user = wp_get_current_user();
|
||||
if ( ! $current_user || 0 === (int) $current_user->ID ) {
|
||||
WP_CLI::warning( __( 'No user context. Pass --user=<id|login> so post updates carry an author and capability checks apply.', 'robotstxt-ai-translator' ) );
|
||||
}
|
||||
|
||||
if ( ! $dry_run && ! $this->translator->is_available() ) {
|
||||
WP_CLI::error( __( 'The WordPress AI plugin is not active.', 'robotstxt-ai-translator' ) );
|
||||
}
|
||||
|
||||
if ( ! $dry_run && ! $this->translator->is_supported() ) {
|
||||
WP_CLI::error( __( 'The WordPress AI plugin has no provider configured for text generation.', 'robotstxt-ai-translator' ) );
|
||||
}
|
||||
|
||||
if ( ! in_array( $locale, $this->translator->get_installed_locales(), true ) ) {
|
||||
WP_CLI::error(
|
||||
sprintf(
|
||||
/* translators: %s: locale code. */
|
||||
__( 'The locale %s is not installed on this site.', 'robotstxt-ai-translator' ),
|
||||
$locale
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$settings = $this->settings->get_settings();
|
||||
|
||||
if ( empty( $settings['translate_title'] ) && empty( $settings['translate_content'] ) ) {
|
||||
WP_CLI::error( __( 'No translation fields are enabled in the plugin settings.', 'robotstxt-ai-translator' ) );
|
||||
}
|
||||
|
||||
if ( $post_id > 0 ) {
|
||||
$posts = array( get_post( $post_id ) );
|
||||
} else {
|
||||
$type = '' !== $type ? $type : 'post';
|
||||
$posts = get_posts(
|
||||
array(
|
||||
'post_type' => $type,
|
||||
'post_status' => $status,
|
||||
'numberposts' => $limit,
|
||||
'ignore_sticky_posts' => true,
|
||||
'suppress_filters' => false,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$posts = array_filter(
|
||||
$posts,
|
||||
static function ( $post ) {
|
||||
return $post instanceof WP_Post;
|
||||
}
|
||||
);
|
||||
|
||||
if ( empty( $posts ) ) {
|
||||
WP_CLI::warning( __( 'No posts matched the given criteria.', 'robotstxt-ai-translator' ) );
|
||||
return;
|
||||
}
|
||||
|
||||
$successes = 0;
|
||||
$failures = 0;
|
||||
|
||||
foreach ( $posts as $post ) {
|
||||
if ( $dry_run ) {
|
||||
WP_CLI::log( sprintf( '[dry-run] would translate post #%d (%s) to %s.', $post->ID, $post->post_title, $locale ) );
|
||||
++$successes;
|
||||
continue;
|
||||
}
|
||||
|
||||
$update = array( 'ID' => $post->ID );
|
||||
|
||||
if ( ! empty( $settings['translate_title'] ) ) {
|
||||
$translated_title = $this->translator->translate( $post->post_title, $locale );
|
||||
if ( is_wp_error( $translated_title ) ) {
|
||||
WP_CLI::warning( sprintf( '#%d title: %s', $post->ID, $translated_title->get_error_message() ) );
|
||||
++$failures;
|
||||
continue;
|
||||
}
|
||||
$update['post_title'] = $translated_title;
|
||||
}
|
||||
|
||||
if ( ! empty( $settings['translate_content'] ) ) {
|
||||
$translated_content = $this->translator->translate( $post->post_content, $locale );
|
||||
if ( is_wp_error( $translated_content ) ) {
|
||||
WP_CLI::warning( sprintf( '#%d content: %s', $post->ID, $translated_content->get_error_message() ) );
|
||||
++$failures;
|
||||
continue;
|
||||
}
|
||||
$update['post_content'] = $translated_content;
|
||||
}
|
||||
|
||||
$result = wp_update_post( $update, true );
|
||||
|
||||
if ( is_wp_error( $result ) ) {
|
||||
WP_CLI::warning( sprintf( '#%d save: %s', $post->ID, $result->get_error_message() ) );
|
||||
++$failures;
|
||||
continue;
|
||||
}
|
||||
|
||||
WP_CLI::log( sprintf( 'Updated post #%d.', $post->ID ) );
|
||||
++$successes;
|
||||
}
|
||||
|
||||
WP_CLI::success(
|
||||
sprintf(
|
||||
/* translators: 1: success count, 2: failure count, 3: locale. */
|
||||
__( '%1$d translated, %2$d failed (locale: %3$s).', 'robotstxt-ai-translator' ),
|
||||
$successes,
|
||||
$failures,
|
||||
$locale
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
549
includes/class-ai-translator-editor-ui.php
Normal file
549
includes/class-ai-translator-editor-ui.php
Normal file
|
|
@ -0,0 +1,549 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
}
|
||||
210
includes/class-ai-translator-settings.php
Normal file
210
includes/class-ai-translator-settings.php
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
<?php
|
||||
/**
|
||||
* Settings management for the AI Translator plugin.
|
||||
*
|
||||
* @package ROBOTSTXT\AI_Translator
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! class_exists( 'AI_Translator_Settings' ) ) {
|
||||
|
||||
/**
|
||||
* Handles reading and writing the plugin settings in single-site and Multisite contexts.
|
||||
*
|
||||
* In Multisite, the network admin chooses between two modes:
|
||||
* - "global" : a single configuration shared by every subsite.
|
||||
* - "per-site" : each subsite stores its own configuration, with the network values
|
||||
* acting as defaults when a subsite has not been configured yet.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class AI_Translator_Settings {
|
||||
|
||||
/**
|
||||
* Option key for site-level settings (single-site or per-site Multisite mode).
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @var string
|
||||
*/
|
||||
const OPTION_SITE = 'ai_translator_settings';
|
||||
|
||||
/**
|
||||
* Sitemeta key for network-level settings (Multisite global mode and defaults).
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @var string
|
||||
*/
|
||||
const OPTION_NETWORK = 'ai_translator_settings';
|
||||
|
||||
/**
|
||||
* Sitemeta key for the configuration mode in Multisite.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @var string
|
||||
*/
|
||||
const OPTION_MODE = 'ai_translator_network_mode';
|
||||
|
||||
/**
|
||||
* Default settings values.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @var array<string,bool>
|
||||
*/
|
||||
const DEFAULTS = array(
|
||||
'translate_title' => true,
|
||||
'translate_content' => true,
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns the current configuration mode in Multisite.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return string Either 'global' or 'per-site'. Returns 'global' on single-site installs.
|
||||
*/
|
||||
public function get_network_mode() {
|
||||
if ( ! is_multisite() ) {
|
||||
return 'global';
|
||||
}
|
||||
|
||||
$mode = get_site_option( self::OPTION_MODE, 'global' );
|
||||
|
||||
return in_array( $mode, array( 'global', 'per-site' ), true ) ? $mode : 'global';
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the Multisite configuration mode.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param string $mode Either 'global' or 'per-site'.
|
||||
*
|
||||
* @return bool True on success.
|
||||
*/
|
||||
public function update_network_mode( $mode ) {
|
||||
if ( ! is_multisite() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$mode = in_array( $mode, array( 'global', 'per-site' ), true ) ? $mode : 'global';
|
||||
|
||||
return (bool) update_site_option( self::OPTION_MODE, $mode );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the effective settings for a given site, applying multisite mode rules.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param int|null $blog_id Optional blog ID. Defaults to the current site.
|
||||
*
|
||||
* @return array<string,bool> Settings array with boolean values.
|
||||
*/
|
||||
public function get_settings( $blog_id = null ) {
|
||||
if ( ! is_multisite() ) {
|
||||
return $this->normalize( (array) get_option( self::OPTION_SITE, array() ) );
|
||||
}
|
||||
|
||||
$mode = $this->get_network_mode();
|
||||
|
||||
if ( 'global' === $mode ) {
|
||||
return $this->normalize( (array) get_site_option( self::OPTION_NETWORK, array() ) );
|
||||
}
|
||||
|
||||
$network_defaults = $this->normalize( (array) get_site_option( self::OPTION_NETWORK, array() ) );
|
||||
|
||||
if ( null === $blog_id || get_current_blog_id() === (int) $blog_id ) {
|
||||
$site_settings = get_option( self::OPTION_SITE, null );
|
||||
} else {
|
||||
switch_to_blog( (int) $blog_id );
|
||||
$site_settings = get_option( self::OPTION_SITE, null );
|
||||
restore_current_blog();
|
||||
}
|
||||
|
||||
if ( null === $site_settings ) {
|
||||
return $network_defaults;
|
||||
}
|
||||
|
||||
return $this->normalize( array_merge( $network_defaults, (array) $site_settings ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw network-level settings (used by the network admin form).
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return array<string,bool>
|
||||
*/
|
||||
public function get_network_settings() {
|
||||
return $this->normalize( (array) get_site_option( self::OPTION_NETWORK, array() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw site-level settings (used by the site admin form).
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return array<string,bool>
|
||||
*/
|
||||
public function get_site_settings() {
|
||||
return $this->normalize( (array) get_option( self::OPTION_SITE, array() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the network-level settings (Multisite only).
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param array<string,bool> $settings Settings to store.
|
||||
*
|
||||
* @return bool True on success.
|
||||
*/
|
||||
public function update_network_settings( array $settings ) {
|
||||
if ( ! is_multisite() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) update_site_option( self::OPTION_NETWORK, $this->normalize( $settings ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the site-level settings.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param array<string,bool> $settings Settings to store.
|
||||
*
|
||||
* @return bool True on success.
|
||||
*/
|
||||
public function update_site_settings( array $settings ) {
|
||||
return (bool) update_option( self::OPTION_SITE, $this->normalize( $settings ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalises raw settings into a strict array of booleans matching the schema.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param array<int|string,mixed> $settings Raw settings.
|
||||
*
|
||||
* @return array<string,bool>
|
||||
*/
|
||||
private function normalize( array $settings ) {
|
||||
$normalized = array();
|
||||
|
||||
foreach ( self::DEFAULTS as $key => $default ) {
|
||||
if ( array_key_exists( $key, $settings ) ) {
|
||||
$normalized[ $key ] = (bool) $settings[ $key ];
|
||||
} else {
|
||||
$normalized[ $key ] = (bool) $default;
|
||||
}
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
201
includes/class-ai-translator-translator.php
Normal file
201
includes/class-ai-translator-translator.php
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
<?php
|
||||
/**
|
||||
* Translation engine for the AI Translator plugin.
|
||||
*
|
||||
* Wraps the WordPress AI client (https://github.com/WordPress/ai) so the rest of the
|
||||
* plugin does not need to know which AI provider is configured.
|
||||
*
|
||||
* @package ROBOTSTXT\AI_Translator
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! class_exists( 'AI_Translator_Translator' ) ) {
|
||||
|
||||
/**
|
||||
* Talks to the WordPress AI client to translate strings.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class AI_Translator_Translator {
|
||||
|
||||
/**
|
||||
* Indicates whether the WordPress AI client is available.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return bool True if the dependency is active.
|
||||
*/
|
||||
public function is_available() {
|
||||
return function_exists( 'wp_ai_client_prompt' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether the WordPress AI client supports text generation right now.
|
||||
*
|
||||
* The check uses a tiny dummy prompt so the AI client can answer whether at
|
||||
* least one configured provider can serve text generation requests.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return bool True if text generation is supported.
|
||||
*/
|
||||
public function is_supported() {
|
||||
if ( ! $this->is_available() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$builder = wp_ai_client_prompt( 'test' );
|
||||
|
||||
return (bool) $builder->is_supported_for_text_generation();
|
||||
} catch ( Exception $e ) {
|
||||
return false;
|
||||
} catch ( Throwable $e ) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates a single piece of text to the target locale.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param string $text Source text. Plain text or HTML; preserved as-is.
|
||||
* @param string $target_locale WordPress locale code (for example: es_ES, en_US, fr_FR).
|
||||
*
|
||||
* @return string|WP_Error Translated text, or WP_Error on failure.
|
||||
*/
|
||||
public function translate( $text, $target_locale ) {
|
||||
$text = (string) $text;
|
||||
|
||||
if ( '' === trim( $text ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ( ! $this->is_available() ) {
|
||||
return new WP_Error(
|
||||
'ai_translator_unavailable',
|
||||
__( 'The WordPress AI client is not available. Install and activate the WordPress AI plugin.', 'robotstxt-ai-translator' )
|
||||
);
|
||||
}
|
||||
|
||||
if ( ! $this->is_supported() ) {
|
||||
return new WP_Error(
|
||||
'ai_translator_unsupported',
|
||||
__( 'The configured AI providers cannot serve text generation requests. Open the WordPress AI settings to configure a provider.', 'robotstxt-ai-translator' )
|
||||
);
|
||||
}
|
||||
|
||||
$language_name = $this->locale_to_language_name( $target_locale );
|
||||
|
||||
if ( '' === $language_name ) {
|
||||
return new WP_Error(
|
||||
'ai_translator_invalid_locale',
|
||||
__( 'The target language is not installed on this site.', 'robotstxt-ai-translator' )
|
||||
);
|
||||
}
|
||||
|
||||
$system_instruction = sprintf(
|
||||
/* translators: %s: target language name. */
|
||||
__( 'You are a professional translator. Translate the user message into %s. Preserve any HTML, shortcodes, line breaks, and Markdown exactly as they appear. Return only the translated text, without explanations, prefaces, or quotation marks.', 'robotstxt-ai-translator' ),
|
||||
$language_name
|
||||
);
|
||||
|
||||
try {
|
||||
$result = wp_ai_client_prompt( $text )
|
||||
->using_system_instruction( $system_instruction )
|
||||
->generate_text();
|
||||
} catch ( Exception $e ) {
|
||||
return new WP_Error( 'ai_translator_exception', $e->getMessage() );
|
||||
} catch ( Throwable $e ) {
|
||||
return new WP_Error( 'ai_translator_exception', $e->getMessage() );
|
||||
}
|
||||
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
if ( ! is_string( $result ) ) {
|
||||
return new WP_Error(
|
||||
'ai_translator_invalid_response',
|
||||
__( 'The AI service returned an unexpected response.', 'robotstxt-ai-translator' )
|
||||
);
|
||||
}
|
||||
|
||||
return trim( $result );
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a WordPress locale code into a human-readable language name.
|
||||
*
|
||||
* Only locales installed on the site (en_US plus any installed .po files) are
|
||||
* accepted, so the editor cannot request translations to languages that the
|
||||
* site has not declared as available.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param string $locale WordPress locale code.
|
||||
*
|
||||
* @return string Native language name, or empty string when the locale is not installed.
|
||||
*/
|
||||
public function locale_to_language_name( $locale ) {
|
||||
$locale = (string) $locale;
|
||||
|
||||
if ( '' === $locale ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$installed = $this->get_installed_locales();
|
||||
|
||||
if ( ! in_array( $locale, $installed, true ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ( 'en_US' === $locale ) {
|
||||
return 'English (United States)';
|
||||
}
|
||||
|
||||
if ( ! function_exists( 'wp_get_available_translations' ) ) {
|
||||
require_once ABSPATH . 'wp-admin/includes/translation-install.php';
|
||||
}
|
||||
|
||||
if ( function_exists( 'wp_get_available_translations' ) ) {
|
||||
$translations = wp_get_available_translations();
|
||||
if ( isset( $translations[ $locale ]['native_name'] ) && '' !== $translations[ $locale ]['native_name'] ) {
|
||||
return (string) $translations[ $locale ]['native_name'];
|
||||
}
|
||||
if ( isset( $translations[ $locale ]['english_name'] ) && '' !== $translations[ $locale ]['english_name'] ) {
|
||||
return (string) $translations[ $locale ]['english_name'];
|
||||
}
|
||||
}
|
||||
|
||||
return $locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the locales installed on the site, including en_US.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return array<int,string> List of locale codes.
|
||||
*/
|
||||
public function get_installed_locales() {
|
||||
$locales = array( 'en_US' );
|
||||
|
||||
if ( function_exists( 'get_available_languages' ) ) {
|
||||
$installed = get_available_languages();
|
||||
if ( is_array( $installed ) ) {
|
||||
foreach ( $installed as $locale ) {
|
||||
$locales[] = (string) $locale;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values( array_unique( $locales ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue