v1.2.0
This commit is contained in:
parent
02cd01e862
commit
1ccc13da01
14 changed files with 845 additions and 197 deletions
|
|
@ -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 ) ),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' ),
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue