This commit is contained in:
Javier Casares 2026-05-28 07:22:36 +00:00
commit 1ccc13da01
14 changed files with 845 additions and 197 deletions

View file

@ -245,6 +245,40 @@ if ( ! class_exists( 'AI_Translator_Admin' ) ) {
</td>
</tr>
<tr>
<th scope="row">
<label for="ai-translator-request-timeout"><?php echo esc_html__( 'Request timeout', 'robotstxt-ai-translator' ); ?></label>
</th>
<td>
<input
type="number"
id="ai-translator-request-timeout"
name="ai_translator_settings[request_timeout]"
value="<?php echo esc_attr( (string) $current['request_timeout'] ); ?>"
min="30"
max="300"
step="1"
class="small-text"
/>
<span class="description"><?php echo esc_html__( 'seconds', 'robotstxt-ai-translator' ); ?></span>
<p class="description">
<?php
$ini_raw = ini_get( 'max_execution_time' );
$php_limit = is_string( $ini_raw ) ? (int) $ini_raw : 0;
if ( $php_limit > 0 ) {
printf(
/* translators: %d: PHP max_execution_time in seconds. */
esc_html__( 'Maximum time the server waits for an AI response. PHP max_execution_time is %d s — values above this have no effect.', 'robotstxt-ai-translator' ),
absint( $php_limit )
);
} else {
esc_html_e( 'Maximum time the server waits for an AI response. PHP max_execution_time is unlimited.', 'robotstxt-ai-translator' );
}
?>
</p>
</td>
</tr>
<?php if ( class_exists( '\Inpsyde\MultilingualPress\TranslationUi\Post\MetaboxAction' ) ) : ?>
<tr>
<th scope="row"><?php echo esc_html__( 'MultilingualPress', 'robotstxt-ai-translator' ); ?></th>
@ -355,6 +389,40 @@ if ( ! class_exists( 'AI_Translator_Admin' ) ) {
</td>
</tr>
<tr>
<th scope="row">
<label for="ai-translator-net-request-timeout"><?php echo esc_html__( 'Request timeout', 'robotstxt-ai-translator' ); ?></label>
</th>
<td>
<input
type="number"
id="ai-translator-net-request-timeout"
name="ai_translator_settings[request_timeout]"
value="<?php echo esc_attr( (string) $network['request_timeout'] ); ?>"
min="30"
max="300"
step="1"
class="small-text"
/>
<span class="description"><?php echo esc_html__( 'seconds', 'robotstxt-ai-translator' ); ?></span>
<p class="description">
<?php
$ini_raw_net = ini_get( 'max_execution_time' );
$php_limit_net = is_string( $ini_raw_net ) ? (int) $ini_raw_net : 0;
if ( $php_limit_net > 0 ) {
printf(
/* translators: %d: PHP max_execution_time in seconds. */
esc_html__( 'Maximum time the server waits for an AI response. PHP max_execution_time is %d s — values above this have no effect.', 'robotstxt-ai-translator' ),
absint( $php_limit_net )
);
} else {
esc_html_e( 'Maximum time the server waits for an AI response. PHP max_execution_time is unlimited.', 'robotstxt-ai-translator' );
}
?>
</p>
</td>
</tr>
<?php if ( class_exists( '\Inpsyde\MultilingualPress\TranslationUi\Post\MetaboxAction' ) ) : ?>
<tr>
<th scope="row"><?php echo esc_html__( 'MultilingualPress', 'robotstxt-ai-translator' ); ?></th>
@ -563,18 +631,18 @@ if ( ! class_exists( 'AI_Translator_Admin' ) ) {
}
/**
* Sanitises a raw settings array into a strict boolean schema.
* Sanitises a raw settings array into a typed schema.
*
* This is the single sanitisation boundary for all form input from both the
* site and network settings pages. Raw POST values arrive via wp_unslash()
* and are converted here to strict booleans. Any new field added to this
* schema MUST be sanitised in this method before being stored.
* and are converted here to strict types. Any new field added to this schema
* MUST be sanitised in this method before being stored.
*
* @since 1.0.0
*
* @param array<string,mixed> $raw Raw input from the form.
*
* @return array<string,bool>
* @return array<string,bool|int>
*/
private function sanitize_settings( array $raw ) {
return array(
@ -582,6 +650,7 @@ if ( ! class_exists( 'AI_Translator_Admin' ) ) {
'translate_content' => ! empty( $raw['translate_content'] ),
'translate_excerpt' => ! empty( $raw['translate_excerpt'] ),
'auto_translate_on_mlp_create' => ! empty( $raw['auto_translate_on_mlp_create'] ),
'request_timeout' => max( 30, min( 300, isset( $raw['request_timeout'] ) && is_numeric( $raw['request_timeout'] ) ? (int) $raw['request_timeout'] : 60 ) ),
);
}
}

View file

@ -119,6 +119,41 @@ if ( ! class_exists( 'AI_Translator_Editor_UI' ) ) {
),
)
);
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',
),
),
)
);
}
/**
@ -141,6 +176,79 @@ if ( ! class_exists( 'AI_Translator_Editor_UI' ) ) {
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.
*
@ -428,31 +536,35 @@ if ( ! class_exists( 'AI_Translator_Editor_UI' ) ) {
$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(
'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' ),
'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' ),
'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' ),
),
);

View file

@ -51,14 +51,17 @@ if ( ! class_exists( 'AI_Translator_Settings' ) ) {
/**
* Default settings values.
*
* Boolean keys are coerced to bool on read/write; integer keys to int.
*
* @since 1.0.0
* @var array<string,bool>
* @var array<string,bool|int>
*/
const DEFAULTS = array(
'translate_title' => true,
'translate_content' => true,
'translate_excerpt' => true,
'auto_translate_on_mlp_create' => false,
'request_timeout' => 60,
);
/**
@ -104,7 +107,7 @@ if ( ! class_exists( 'AI_Translator_Settings' ) ) {
*
* @param int|null $blog_id Optional blog ID. Defaults to the current site.
*
* @return array<string,bool> Settings array with boolean values.
* @return array<string,bool|int> Settings array with typed values.
*/
public function get_settings( $blog_id = null ) {
if ( ! is_multisite() ) {
@ -139,7 +142,7 @@ if ( ! class_exists( 'AI_Translator_Settings' ) ) {
*
* @since 1.0.0
*
* @return array<string,bool>
* @return array<string,bool|int>
*/
public function get_network_settings() {
return $this->normalize( (array) get_site_option( self::OPTION_NETWORK, array() ) );
@ -150,7 +153,7 @@ if ( ! class_exists( 'AI_Translator_Settings' ) ) {
*
* @since 1.0.0
*
* @return array<string,bool>
* @return array<string,bool|int>
*/
public function get_site_settings() {
return $this->normalize( (array) get_option( self::OPTION_SITE, array() ) );
@ -161,7 +164,7 @@ if ( ! class_exists( 'AI_Translator_Settings' ) ) {
*
* @since 1.0.0
*
* @param array<string,bool> $settings Settings to store.
* @param array<string,bool|int> $settings Settings to store.
*
* @return bool True on success.
*/
@ -178,7 +181,7 @@ if ( ! class_exists( 'AI_Translator_Settings' ) ) {
*
* @since 1.0.0
*
* @param array<string,bool> $settings Settings to store.
* @param array<string,bool|int> $settings Settings to store.
*
* @return bool True on success.
*/
@ -187,22 +190,30 @@ if ( ! class_exists( 'AI_Translator_Settings' ) ) {
}
/**
* Normalises raw settings into a strict array of booleans matching the schema.
* Normalises raw settings into a typed array matching the schema.
*
* Boolean keys are coerced to strict bool; integer keys to strict int.
* The DEFAULTS constant is the authoritative schema: any key not present
* there is silently dropped, and any missing key falls back to its default.
*
* @since 1.0.0
*
* @param array<int|string,mixed> $settings Raw settings.
*
* @return array<string,bool>
* @return array<string,bool|int>
*/
private function normalize( array $settings ) {
$normalized = array();
foreach ( self::DEFAULTS as $key => $default ) {
if ( array_key_exists( $key, $settings ) ) {
$normalized[ $key ] = (bool) $settings[ $key ];
if ( is_int( $default ) ) {
$normalized[ $key ] = (int) $settings[ $key ];
} else {
$normalized[ $key ] = (bool) $settings[ $key ];
}
} else {
$normalized[ $key ] = (bool) $default;
$normalized[ $key ] = $default;
}
}

View file

@ -22,6 +22,52 @@ if ( ! class_exists( 'AI_Translator_Translator' ) ) {
*/
class AI_Translator_Translator {
/**
* Settings handler.
*
* @since 1.2.0
* @var AI_Translator_Settings
*/
private $settings;
/**
* Timeout value (seconds) enforced during an active translate() call.
* Null when no call is in progress.
*
* @since 1.2.0
* @var int|null
*/
private $active_timeout = null;
/**
* Constructor.
*
* @since 1.2.0
*
* @param AI_Translator_Settings $settings Settings handler.
*/
public function __construct( AI_Translator_Settings $settings ) {
$this->settings = $settings;
}
/**
* Filters the http_request_timeout value while an AI translation call is
* in progress. Registered and de-registered by translate() around each call.
*
* @since 1.2.0
*
* @param mixed $timeout Current timeout value passed by WordPress.
*
* @return int
*/
public function filter_timeout( $timeout ) {
if ( null !== $this->active_timeout ) {
return $this->active_timeout;
}
return is_numeric( $timeout ) ? (int) $timeout : 30;
}
/**
* Indicates whether AI features are enabled for this WordPress installation.
*
@ -66,6 +112,9 @@ if ( ! class_exists( 'AI_Translator_Translator' ) ) {
/**
* Translates a single piece of text to the target locale.
*
* Applies the configured request_timeout via the http_request_timeout filter
* for the duration of the AI call, then restores the previous timeout.
*
* @since 1.0.0
*
* @param string $text Source text. Plain text or HTML; preserved as-is.
@ -109,16 +158,24 @@ if ( ! class_exists( 'AI_Translator_Translator' ) ) {
$language_name
);
// Apply the configured request timeout around the AI call.
$site_settings = $this->settings->get_settings();
$this->active_timeout = max( 30, (int) $site_settings['request_timeout'] );
add_filter( 'http_request_timeout', array( $this, 'filter_timeout' ), PHP_INT_MAX );
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() );
$result = new WP_Error( 'ai_translator_exception', $e->getMessage() );
} catch ( Throwable $e ) {
return new WP_Error( 'ai_translator_exception', $e->getMessage() );
$result = new WP_Error( 'ai_translator_exception', $e->getMessage() );
}
remove_filter( 'http_request_timeout', array( $this, 'filter_timeout' ), PHP_INT_MAX );
$this->active_timeout = null;
if ( is_wp_error( $result ) ) {
return $result;
}