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

@ -5,6 +5,10 @@
* Classic editor metabox. Both implementations share the same REST endpoint
* and language list.
*
* Content translation is performed in parallel chunks so that long posts do not
* hit server-side timeout limits. Title and excerpt are short fields and are
* translated in a single request using the standard /translate endpoint.
*
* @package ROBOTSTXT\AI_Translator
* @since 1.0.0
*/
@ -27,12 +31,201 @@
var apiFetch = window.wp && window.wp.apiFetch ? window.wp.apiFetch : null;
var labels = data.i18n || {};
// ── Chunking helpers ──────────────────────────────────────────────────────
/**
* Calls the REST endpoint to translate the selected fields of the saved post.
* Splits Gutenberg block HTML into chunks at block boundaries.
*
* @param {Array<string>} fields Field names to translate ('title', 'content', 'excerpt').
* Each chunk ends on a complete Gutenberg block so the AI never receives
* partial markup. Chunks are kept below maxChars where possible; a single
* block larger than maxChars is sent as its own chunk.
*
* @param {string} content Serialised Gutenberg block HTML.
* @param {number} maxChars Maximum characters per chunk.
* @returns {Array<string>}
*/
function chunkByBlocks( content, maxChars ) {
if ( ! content || content.length <= maxChars ) {
return [ content ];
}
// Split just before each block comment opener so every part starts at a
// block boundary. The split delimiter is a zero-width lookahead so each
// part keeps the opening comment intact.
var parts = content.split( /(?=<!-- wp:)/ );
var chunks = [];
var current = '';
for ( var i = 0; i < parts.length; i++ ) {
var part = parts[ i ];
if ( '' === part ) {
continue;
}
if ( current.length > 0 && current.length + part.length > maxChars ) {
chunks.push( current );
current = part;
} else {
current += part;
}
}
if ( current ) {
chunks.push( current );
}
return chunks.length ? chunks : [ content ];
}
/**
* Splits HTML content (Classic editor) into chunks at paragraph / heading /
* list-item boundaries so the AI never receives partial sentences.
*
* @param {string} html HTML content.
* @param {number} maxChars Maximum characters per chunk.
* @returns {Array<string>}
*/
function chunkByParagraphs( html, maxChars ) {
if ( ! html || html.length <= maxChars ) {
return [ html ];
}
// Match block-level closing tags so we can split after them.
var regex = /(<\/(?:p|h[1-6]|li|blockquote|pre|div|figure)>)(\s*)/gi;
var chunks = [];
var current = '';
var lastIndex = 0;
var match;
while ( ( match = regex.exec( html ) ) !== null ) {
var segEnd = match.index + match[0].length;
var segment = html.slice( lastIndex, segEnd );
lastIndex = segEnd;
if ( current.length > 0 && current.length + segment.length > maxChars ) {
chunks.push( current );
current = segment;
} else {
current += segment;
}
}
// Append any trailing content after the last closing tag.
if ( lastIndex < html.length ) {
current += html.slice( lastIndex );
}
if ( current ) {
chunks.push( current );
}
return chunks.length ? chunks : [ html ];
}
/**
* Returns the current content from the Classic editor.
*
* Prefers the TinyMCE visual editor when it is active; falls back to the
* raw textarea value when TinyMCE is in source (text) mode or absent.
*
* @returns {string}
*/
function getCurrentClassicContent() {
var tinymce = window.tinymce;
if ( tinymce && typeof tinymce.get === 'function' ) {
var ed = tinymce.get( 'content' );
if ( ed && typeof ed.getContent === 'function' ) {
// When TinyMCE is hidden the user is in "Text" (source) mode;
// in that case the textarea holds the latest value.
if ( typeof ed.isHidden === 'function' && ed.isHidden() ) {
var textareaSrc = document.getElementById( 'content' );
return textareaSrc ? textareaSrc.value : '';
}
return ed.getContent();
}
}
var textarea = document.getElementById( 'content' );
return textarea ? textarea.value : '';
}
/**
* Sends a single text chunk to the /translate-text REST endpoint.
*
* @param {string} text Text (may include HTML) to translate.
* @param {string} targetLocale WordPress locale code.
* @returns {Promise<Object>} Promise resolving to the translated values.
* @returns {Promise<string>} The translated text.
*/
function translateChunk( text, targetLocale ) {
if ( ! apiFetch ) {
return Promise.reject( new Error( labels.genericError || 'Translation failed.' ) );
}
return apiFetch( {
path: '/' + data.restNamespace + data.chunkTextRoute,
method: 'POST',
data: {
post_id: data.postId,
text: text,
target_locale: targetLocale,
},
} ).then( function ( response ) {
return ( response && typeof response.text === 'string' ) ? response.text : '';
} );
}
/**
* Translates content by splitting it into chunks and translating them
* concurrently via Promise.all().
*
* Only `content` uses this path. Title and excerpt are short and go through
* the standard /translate endpoint which reads from the database.
*
* @param {string} content Serialised block HTML or Classic editor HTML.
* @param {string} targetLocale WordPress locale code.
* @param {boolean} isBlock True when the content is Gutenberg block HTML.
* @param {Function} onProgress Called with (done, total) after each chunk resolves.
* @returns {Promise<string>} Reassembled translated content.
*/
function translateContentParallel( content, targetLocale, isBlock, onProgress ) {
if ( ! content || '' === content.trim() ) {
return Promise.resolve( '' );
}
var chunkSize = ( data.chunkSize && data.chunkSize > 0 ) ? data.chunkSize : 5000;
var chunks = isBlock
? chunkByBlocks( content, chunkSize )
: chunkByParagraphs( content, chunkSize );
var total = chunks.length;
var done = 0;
if ( typeof onProgress === 'function' ) {
onProgress( 0, total );
}
var promises = chunks.map( function ( chunk ) {
return translateChunk( chunk, targetLocale ).then( function ( translated ) {
done++;
if ( typeof onProgress === 'function' ) {
onProgress( done, total );
}
return translated;
} );
} );
return Promise.all( promises ).then( function ( parts ) {
return parts.join( '' );
} );
}
// ── Shared helpers ────────────────────────────────────────────────────────
/**
* Calls the /translate endpoint to translate title and/or excerpt fields of
* the saved post.
*
* @param {Array<string>} fields Field names ('title', 'excerpt').
* @param {string} targetLocale WordPress locale code.
* @returns {Promise<Object>}
*/
function requestTranslation( fields, targetLocale ) {
if ( ! apiFetch ) {
@ -50,14 +243,11 @@
} );
}
function resolveFields( wantTitle, wantContent, wantExcerpt ) {
function resolveMetaFields( wantTitle, wantExcerpt ) {
var fields = [];
if ( wantTitle && data.settings.translateTitle ) {
fields.push( 'title' );
}
if ( wantContent && data.settings.translateContent ) {
fields.push( 'content' );
}
if ( wantExcerpt && data.settings.translateExcerpt ) {
fields.push( 'excerpt' );
}
@ -72,59 +262,32 @@
}
/**
* Applies a translation response using the right method for the current editor.
* Applies translated content to the Block editor.
*
* In the block editor, uses wp.data dispatch (and wp.blocks.parse for content).
* In the classic editor, updates the DOM directly (title input + TinyMCE + excerpt textarea).
*
* @param {Object} response Translation response.
* @param {Document} doc Document where the metabox lives (for classic fallback).
* @param {string} content Serialised block HTML.
*/
function applyTranslationResult( response, doc ) {
function applyContentToBlockEditor( content ) {
var wp = window.wp || {};
var isBlockCtx = data.context === 'block' && wp.data && wp.data.dispatch;
if ( isBlockCtx ) {
if ( response.title !== undefined ) {
wp.data.dispatch( 'core/editor' ).editPost( { title: response.title } );
if ( ! wp.data || ! wp.data.dispatch ) {
return;
}
if ( response.content !== undefined ) {
if ( wp.blocks && typeof wp.blocks.parse === 'function' ) {
var blocks = wp.blocks.parse( response.content );
var blocks = wp.blocks.parse( content );
var blockEd = wp.data.dispatch( 'core/block-editor' );
if ( blockEd && typeof blockEd.resetBlocks === 'function' ) {
blockEd.resetBlocks( blocks );
} else {
wp.data.dispatch( 'core/editor' ).editPost( { content: response.content } );
}
} else {
wp.data.dispatch( 'core/editor' ).editPost( { content: response.content } );
}
}
if ( response.excerpt !== undefined ) {
wp.data.dispatch( 'core/editor' ).editPost( { excerpt: response.excerpt } );
}
return;
}
doc = doc || document;
if ( response.title !== undefined ) {
var titleInput = doc.getElementById( 'title' );
if ( titleInput ) {
titleInput.value = response.title;
}
}
if ( response.content !== undefined ) {
applyContentToClassic( response.content, doc );
}
if ( response.excerpt !== undefined ) {
var excerptTextarea = doc.getElementById( 'excerpt' );
if ( excerptTextarea ) {
excerptTextarea.value = response.excerpt;
}
}
wp.data.dispatch( 'core/editor' ).editPost( { content: content } );
}
/**
* Applies translated content to the Classic editor (textarea + TinyMCE).
*
* @param {string} content HTML content.
* @param {Document} doc Document where the editor lives.
*/
function applyContentToClassic( content, doc ) {
doc = doc || document;
var textarea = doc.getElementById( 'content' );
@ -139,9 +302,43 @@
}
}
// -------------------------------------------------------------------------
// Classic editor integration (metabox).
// -------------------------------------------------------------------------
/**
* Applies a translation response (title / excerpt) using the right method
* for the current editor. Content is handled separately via chunked translation.
*
* @param {Object} response Translation response (may contain title, excerpt).
* @param {Document} doc Document where the Classic editor lives.
*/
function applyMetaTranslation( response, doc ) {
var wp = window.wp || {};
var isBlockCtx = data.context === 'block' && wp.data && wp.data.dispatch;
if ( isBlockCtx ) {
if ( response.title !== undefined ) {
wp.data.dispatch( 'core/editor' ).editPost( { title: response.title } );
}
if ( response.excerpt !== undefined ) {
wp.data.dispatch( 'core/editor' ).editPost( { excerpt: response.excerpt } );
}
return;
}
doc = doc || document;
if ( response.title !== undefined ) {
var titleInput = doc.getElementById( 'title' );
if ( titleInput ) {
titleInput.value = response.title;
}
}
if ( response.excerpt !== undefined ) {
var excerptTextarea = doc.getElementById( 'excerpt' );
if ( excerptTextarea ) {
excerptTextarea.value = response.excerpt;
}
}
}
// ── Classic editor integration (metabox) ──────────────────────────────────
function initClassicEditor() {
var container = document.getElementById( 'ai-translator-classic' );
@ -171,12 +368,13 @@
return;
}
var fields = resolveFields(
var wantContent = data.settings.translateContent;
var metaFields = resolveMetaFields(
data.settings.translateTitle,
data.settings.translateContent,
data.settings.translateExcerpt
);
if ( fields.length === 0 ) {
if ( metaFields.length === 0 && ! wantContent ) {
setStatus( labels.noFields || '', 'warning' );
return;
}
@ -185,9 +383,40 @@
button.dataset.busy = '1';
setStatus( labels.translating || 'Translating…', 'busy' );
requestTranslation( fields, locale )
.then( function ( response ) {
applyTranslationResult( response, document );
var metaPromise = metaFields.length > 0
? requestTranslation( metaFields, locale )
: Promise.resolve( null );
var contentPromise = wantContent
? translateContentParallel(
getCurrentClassicContent(),
locale,
false,
function ( done, total ) {
if ( total > 1 ) {
setStatus(
( labels.translatingProgress || 'Translating… ({done}/{total})' )
.replace( '{done}', String( done ) )
.replace( '{total}', String( total ) ),
'busy'
);
}
}
)
: Promise.resolve( null );
Promise.all( [ metaPromise, contentPromise ] )
.then( function ( results ) {
var metaResult = results[0];
var contentResult = results[1];
if ( metaResult ) {
applyMetaTranslation( metaResult, document );
}
if ( null !== contentResult ) {
applyContentToClassic( contentResult, document );
}
setStatus( labels.success || 'Translation applied.', 'success' );
} )
.catch( function ( error ) {
@ -200,9 +429,7 @@
} );
}
// -------------------------------------------------------------------------
// Block editor integration (PluginSidebar).
// -------------------------------------------------------------------------
// ── Block editor integration (PluginSidebar) ──────────────────────────────
function initBlockEditor() {
var wp = window.wp || {};
@ -248,6 +475,11 @@
var busy = busyState[0];
var setBusy = busyState[1];
// progress: null = idle, { done: number, total: number }
var progressState = useState( null );
var progress = progressState[0];
var setProgress = progressState[1];
var noticeState = useState( null );
var notice = noticeState[0];
var setNotice = noticeState[1];
@ -290,26 +522,63 @@
} );
function runTranslate() {
var fields = resolveFields( translateTitle, translateContent, translateExcerpt );
var wantTitle = translateTitle && data.settings.translateTitle;
var wantContent = translateContent && data.settings.translateContent;
var wantExcerpt = translateExcerpt && data.settings.translateExcerpt;
if ( fields.length === 0 ) {
if ( ! wantTitle && ! wantContent && ! wantExcerpt ) {
setNotice( { status: 'warning', message: labels.noFields || '' } );
return;
}
setBusy( true );
setProgress( null );
setNotice( null );
applyNoticeSnackbar( 'info', labels.translating || 'Translating…' );
requestTranslation( fields, locale )
.then( function ( response ) {
applyTranslationResult( response );
// Title + excerpt: single request against the saved post in the DB.
var metaFields = resolveMetaFields( wantTitle, wantExcerpt );
var metaPromise = metaFields.length > 0
? requestTranslation( metaFields, locale )
: Promise.resolve( null );
// Content: chunked parallel translation of the current editor state.
var contentSource = wantContent
? ( coreEditor && typeof coreEditor.getEditedPostAttribute === 'function'
? ( coreEditor.getEditedPostAttribute( 'content' ) || '' )
: '' )
: null;
var contentPromise = ( null !== contentSource && '' !== contentSource )
? translateContentParallel(
contentSource,
locale,
true,
function ( done, total ) {
setProgress( { done: done, total: total } );
}
)
: Promise.resolve( null );
Promise.all( [ metaPromise, contentPromise ] )
.then( function ( results ) {
var metaResult = results[0];
var contentResult = results[1];
if ( metaResult ) {
applyMetaTranslation( metaResult );
}
if ( null !== contentResult ) {
applyContentToBlockEditor( contentResult );
}
setNotice( { status: 'success', message: labels.success || '' } );
setProgress( null );
applyNoticeSnackbar( 'success', labels.success || 'Translation applied.' );
} )
.catch( function ( error ) {
var errorMessage = describeError( error );
setNotice( { status: 'error', message: errorMessage } );
setProgress( null );
applyNoticeSnackbar( 'error', errorMessage );
} )
.then( function () {
@ -317,6 +586,20 @@
} );
}
// Compute the button label based on busy/progress state.
var buttonLabel;
if ( busy ) {
if ( progress && progress.total > 1 ) {
buttonLabel = ( labels.translatingProgress || 'Translating… ({done}/{total})' )
.replace( '{done}', String( progress.done ) )
.replace( '{total}', String( progress.total ) );
} else {
buttonLabel = labels.translating || 'Translating…';
}
} else {
buttonLabel = labels.translate || 'Translate';
}
var children = [];
if ( isDirty ) {
@ -387,7 +670,7 @@
disabled: busy || ! locale,
isBusy: busy,
onClick: runTranslate,
}, busy ? ( labels.translating || '' ) : ( labels.translate || '' ) ) );
}, buttonLabel ) );
return el( 'div', { className: 'ai-translator-sidebar' }, children );
}

View file

@ -1,5 +1,38 @@
== Changelog ==
= 1.2.0 =
_Release date: 2026-05-28_
**Highlights**
* Parallel chunked content translation: long posts are split at block or paragraph boundaries and all chunks are translated concurrently, eliminating timeout errors.
* Configurable request timeout: control how long the server waits for an AI response (30300 s).
**Added**
* Configurable "Request timeout" setting (default 60 s, minimum 30 s, maximum 300 s) in both site and network admin. Controls how long the server waits for a single AI response before timing out. The PHP `max_execution_time` value is shown as a hint.
* Parallel chunked content translation — long post content is automatically split at Gutenberg block boundaries (Block editor) or paragraph/heading boundaries (Classic editor) and all chunks are sent to the AI concurrently via `Promise.all()`.
* Progress indicator — the Translate button label updates in real time: "Translating… (2/5)" after each chunk resolves.
* New REST endpoint `POST /wp-json/ai-translator/v1/translate-text` — translates a raw text string (HTML or plain text) supplied directly in the request body. Used internally by the chunked content path. Requires the same `edit_post` capability as the existing `/translate` endpoint.
**Changed**
* Content translation now reads directly from the current editor state (unsaved changes are included) instead of from the last saved post revision. Title and excerpt continue to be read from the database.
**Compatibility**
* WordPress: 7.0 - 7.1
* PHP: 7.4 - 8.5
* WP-CLI: 2.12 or newer
**Tests**
* PHP Coding Standards: WordPress-Core, WordPress-Docs, WordPress-Extra
* PHPCompatibility: 8.2 - 8.5
* PHPStan: level 9
* PHPUnit: 51/51 tests passing (single-site)
= 1.1.0 =
_Release date: 2026-05-25_

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.
*
@ -430,6 +538,8 @@ if ( ! class_exists( 'AI_Translator_Editor_UI' ) ) {
$data = array(
'restNamespace' => AI_TRANSLATOR_REST_NAMESPACE,
'restRoute' => '/translate',
'chunkTextRoute' => '/translate-text',
'chunkSize' => 5000,
'context' => $context,
'postId' => $post_id,
'available' => $this->translator->is_available(),
@ -442,6 +552,8 @@ if ( ! class_exists( 'AI_Translator_Editor_UI' ) ) {
'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' ),

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) $default;
$normalized[ $key ] = (bool) $settings[ $key ];
}
} else {
$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;
}

View file

@ -1,6 +1,6 @@
msgid ""
msgstr ""
"Project-Id-Version: AI Translator (by ROBOTSTXT) 1.1.0\n"
"Project-Id-Version: AI Translator (by ROBOTSTXT) 1.2.0\n"
"Report-Msgid-Bugs-To: https://git.robotstxt.es/ROBOTSTXT/robotstxt-ai-translator\n"
"Last-Translator: ROBOTSTXT <hello@robotstxt.es>\n"
"Language-Team: Catalan\n"
@ -407,3 +407,26 @@ msgstr "AI Translator (by ROBOTSTXT) requereix un proveïdor d'IA configurat per
#: robotstxt-ai-translator.php:145
msgid "Settings → AI"
msgstr "Ajustos → IA"
#: includes/class-ai-translator-admin.php
msgid "Request timeout"
msgstr "Temps màxim de resposta"
#: includes/class-ai-translator-admin.php
msgid "seconds"
msgstr "segons"
#. translators: %d: PHP max_execution_time in seconds.
#: includes/class-ai-translator-admin.php
#, php-format
msgid "Maximum time the server waits for an AI response. PHP max_execution_time is %d s — values above this have no effect."
msgstr "Temps màxim que el servidor espera una resposta de la IA. El max_execution_time de PHP és de %d s — els valors superiors no tenen efecte."
#: includes/class-ai-translator-admin.php
msgid "Maximum time the server waits for an AI response. PHP max_execution_time is unlimited."
msgstr "Temps màxim que el servidor espera una resposta de la IA. El max_execution_time de PHP és il·limitat."
#. translators: 1: number of chunks translated so far, 2: total number of chunks.
#: includes/class-ai-translator-editor-ui.php
msgid "Translating… ({done}/{total})"
msgstr "Traduint… ({done}/{total})"

View file

@ -1,6 +1,6 @@
msgid ""
msgstr ""
"Project-Id-Version: AI Translator (by ROBOTSTXT) 1.1.0\n"
"Project-Id-Version: AI Translator (by ROBOTSTXT) 1.2.0\n"
"Report-Msgid-Bugs-To: https://git.robotstxt.es/ROBOTSTXT/robotstxt-ai-translator\n"
"Last-Translator: ROBOTSTXT <hello@robotstxt.es>\n"
"Language-Team: Spanish (Spain)\n"
@ -407,3 +407,26 @@ msgstr "AI Translator (by ROBOTSTXT) requiere un proveedor de IA configurado par
#: robotstxt-ai-translator.php:145
msgid "Settings → AI"
msgstr "Ajustes → IA"
#: includes/class-ai-translator-admin.php
msgid "Request timeout"
msgstr "Tiempo máximo de respuesta"
#: includes/class-ai-translator-admin.php
msgid "seconds"
msgstr "segundos"
#. translators: %d: PHP max_execution_time in seconds.
#: includes/class-ai-translator-admin.php
#, php-format
msgid "Maximum time the server waits for an AI response. PHP max_execution_time is %d s — values above this have no effect."
msgstr "Tiempo máximo que el servidor espera una respuesta de la IA. El max_execution_time de PHP es de %d s — los valores superiores no tienen efecto."
#: includes/class-ai-translator-admin.php
msgid "Maximum time the server waits for an AI response. PHP max_execution_time is unlimited."
msgstr "Tiempo máximo que el servidor espera una respuesta de la IA. El max_execution_time de PHP es ilimitado."
#. translators: 1: number of chunks translated so far, 2: total number of chunks.
#: includes/class-ai-translator-editor-ui.php
msgid "Translating… ({done}/{total})"
msgstr "Traduciendo… ({done}/{total})"

View file

@ -2,14 +2,14 @@
# This file is distributed under the GPL-3.0-or-later.
msgid ""
msgstr ""
"Project-Id-Version: AI Translator (by ROBOTSTXT) 1.1.0\n"
"Project-Id-Version: AI Translator (by ROBOTSTXT) 1.2.0\n"
"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/robotstxt-ai-translator\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"POT-Creation-Date: 2026-05-25T16:53:01+00:00\n"
"POT-Creation-Date: 2026-05-28T06:44:22+00:00\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"X-Generator: WP-CLI 2.12.0\n"
"X-Domain: robotstxt-ai-translator\n"
@ -51,8 +51,8 @@ msgstr ""
#: includes/class-ai-translator-admin.php:113
#: includes/class-ai-translator-admin.php:130
#: includes/class-ai-translator-admin.php:131
#: includes/class-ai-translator-editor-ui.php:294
#: includes/class-ai-translator-editor-ui.php:446
#: includes/class-ai-translator-editor-ui.php:402
#: includes/class-ai-translator-editor-ui.php:558
msgid "AI Translator"
msgstr ""
@ -62,7 +62,7 @@ msgid "Settings"
msgstr ""
#: includes/class-ai-translator-admin.php:187
#: includes/class-ai-translator-admin.php:287
#: includes/class-ai-translator-admin.php:321
msgid "You do not have permission to access this page."
msgstr ""
@ -76,26 +76,26 @@ msgstr ""
#: includes/class-ai-translator-admin.php:211
#: includes/class-ai-translator-admin.php:214
#: includes/class-ai-translator-admin.php:330
#: includes/class-ai-translator-admin.php:334
#: includes/class-ai-translator-admin.php:364
#: includes/class-ai-translator-admin.php:368
msgid "Translation fields"
msgstr ""
#: includes/class-ai-translator-admin.php:218
#: includes/class-ai-translator-admin.php:338
#: includes/class-ai-translator-editor-ui.php:453
#: includes/class-ai-translator-admin.php:372
#: includes/class-ai-translator-editor-ui.php:565
msgid "Translate title"
msgstr ""
#: includes/class-ai-translator-admin.php:223
#: includes/class-ai-translator-admin.php:343
#: includes/class-ai-translator-editor-ui.php:454
#: includes/class-ai-translator-admin.php:377
#: includes/class-ai-translator-editor-ui.php:566
msgid "Translate content"
msgstr ""
#: includes/class-ai-translator-admin.php:228
#: includes/class-ai-translator-admin.php:348
#: includes/class-ai-translator-editor-ui.php:455
#: includes/class-ai-translator-admin.php:382
#: includes/class-ai-translator-editor-ui.php:567
msgid "Translate excerpt"
msgstr ""
@ -118,169 +118,191 @@ msgid "disabled"
msgstr ""
#: includes/class-ai-translator-admin.php:250
#: includes/class-ai-translator-admin.php:253
#: includes/class-ai-translator-admin.php:360
#: includes/class-ai-translator-admin.php:363
#: includes/class-ai-translator-admin.php:394
msgid "Request timeout"
msgstr ""
#: includes/class-ai-translator-admin.php:263
#: includes/class-ai-translator-admin.php:407
msgid "seconds"
msgstr ""
#. translators: %d: PHP max_execution_time in seconds.
#: includes/class-ai-translator-admin.php:271
#: includes/class-ai-translator-admin.php:415
#, php-format
msgid "Maximum time the server waits for an AI response. PHP max_execution_time is %d s — values above this have no effect."
msgstr ""
#: includes/class-ai-translator-admin.php:275
#: includes/class-ai-translator-admin.php:419
msgid "Maximum time the server waits for an AI response. PHP max_execution_time is unlimited."
msgstr ""
#: includes/class-ai-translator-admin.php:284
#: includes/class-ai-translator-admin.php:287
#: includes/class-ai-translator-admin.php:428
#: includes/class-ai-translator-admin.php:431
msgid "MultilingualPress"
msgstr ""
#: includes/class-ai-translator-admin.php:257
#: includes/class-ai-translator-admin.php:367
#: includes/class-ai-translator-admin.php:291
#: includes/class-ai-translator-admin.php:435
msgid "Auto-translate new connected posts"
msgstr ""
#: includes/class-ai-translator-admin.php:261
#: includes/class-ai-translator-admin.php:371
#: includes/class-ai-translator-admin.php:295
#: includes/class-ai-translator-admin.php:439
msgid "When MultilingualPress creates a new connected post, automatically translate its enabled fields into the target site's language. The translation runs synchronously during save."
msgstr ""
#: includes/class-ai-translator-admin.php:296
#: includes/class-ai-translator-admin.php:330
msgid "AI Translator Network Settings"
msgstr ""
#: includes/class-ai-translator-admin.php:300
#: includes/class-ai-translator-admin.php:334
msgid "Network settings saved."
msgstr ""
#: includes/class-ai-translator-admin.php:310
#: includes/class-ai-translator-admin.php:313
#: includes/class-ai-translator-admin.php:344
#: includes/class-ai-translator-admin.php:347
msgid "Configuration mode"
msgstr ""
#: includes/class-ai-translator-admin.php:317
#: includes/class-ai-translator-admin.php:351
msgid "Global configuration: a single setting applies to every site."
msgstr ""
#: includes/class-ai-translator-admin.php:322
#: includes/class-ai-translator-admin.php:356
msgid "Per-site configuration: each site can override these defaults."
msgstr ""
#: includes/class-ai-translator-admin.php:352
#: includes/class-ai-translator-admin.php:386
msgid "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."
msgstr ""
#: includes/class-ai-translator-admin.php:397
#: includes/class-ai-translator-admin.php:429
#: includes/class-ai-translator-admin.php:465
#: includes/class-ai-translator-admin.php:497
msgid "You do not have permission to perform this action."
msgstr ""
#: includes/class-ai-translator-admin.php:473
#: includes/class-ai-translator-admin.php:541
msgid "Recommended models"
msgstr ""
#: includes/class-ai-translator-admin.php:476
#: includes/class-ai-translator-admin.php:544
msgid "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."
msgstr ""
#: includes/class-ai-translator-admin.php:482
#: includes/class-ai-translator-admin.php:550
msgid "Use case"
msgstr ""
#: includes/class-ai-translator-admin.php:483
#: includes/class-ai-translator-admin.php:551
msgid "Recommended model"
msgstr ""
#: includes/class-ai-translator-admin.php:484
#: includes/class-ai-translator-admin.php:552
msgid "Why"
msgstr ""
#: includes/class-ai-translator-admin.php:499
#: includes/class-ai-translator-admin.php:567
msgid "These recommendations are based on public benchmarks and community feedback as of the plugin release date. They may change as providers update their models."
msgstr ""
#: includes/class-ai-translator-admin.php:514
#: includes/class-ai-translator-admin.php:582
msgid "European languages (ES, CA, FR, DE, IT, PT)"
msgstr ""
#: includes/class-ai-translator-admin.php:515
#: includes/class-ai-translator-admin.php:583
msgid "DeepL API Pro"
msgstr ""
#: includes/class-ai-translator-admin.php:516
#: includes/class-ai-translator-admin.php:584
msgid "Best fluency and naturalness (92/100 in benchmarks). Formality control and custom glossaries."
msgstr ""
#: includes/class-ai-translator-admin.php:519
#: includes/class-ai-translator-admin.php:587
msgid "Asian languages (ZH, JA, KO)"
msgstr ""
#: includes/class-ai-translator-admin.php:520
#: includes/class-ai-translator-admin.php:588
msgid "GPT-4o / GPT-5 or Claude Sonnet 4"
msgstr ""
#: includes/class-ai-translator-admin.php:521
#: includes/class-ai-translator-admin.php:589
msgid "Better handling of implicit subjects, honorifics, and cultural references. DeepL falls behind here."
msgstr ""
#: includes/class-ai-translator-admin.php:524
#: includes/class-ai-translator-admin.php:592
msgid "Marketing / brand tone"
msgstr ""
#: includes/class-ai-translator-admin.php:525
#: includes/class-ai-translator-admin.php:593
msgid "Claude Sonnet 4 / Opus 4"
msgstr ""
#: includes/class-ai-translator-admin.php:526
#: includes/class-ai-translator-admin.php:594
msgid "Better preservation of tone, brand voice, and nuance. Ideal for creative copy."
msgstr ""
#: includes/class-ai-translator-admin.php:529
#: includes/class-ai-translator-admin.php:597
msgid "Technical documentation / code"
msgstr ""
#: includes/class-ai-translator-admin.php:530
#: includes/class-ai-translator-admin.php:598
msgid "GPT-4o / GPT-5"
msgstr ""
#: includes/class-ai-translator-admin.php:531
#: includes/class-ai-translator-admin.php:599
msgid "Higher accuracy with variables, structured formats, and technical terminology."
msgstr ""
#: includes/class-ai-translator-admin.php:534
#: includes/class-ai-translator-admin.php:602
msgid "Long documents (100+ pages)"
msgstr ""
#: includes/class-ai-translator-admin.php:535
#: includes/class-ai-translator-admin.php:603
msgid "Gemini 2.5 Pro"
msgstr ""
#: includes/class-ai-translator-admin.php:536
#: includes/class-ai-translator-admin.php:604
msgid "1M token context window. Maintains terminological consistency across long texts."
msgstr ""
#: includes/class-ai-translator-admin.php:539
#: includes/class-ai-translator-admin.php:607
msgid "High volume / low cost"
msgstr ""
#: includes/class-ai-translator-admin.php:540
#: includes/class-ai-translator-admin.php:608
msgid "DeepSeek-V3"
msgstr ""
#: includes/class-ai-translator-admin.php:541
#: includes/class-ai-translator-admin.php:609
msgid "Quality comparable to GPT-5 at ~$0.14/M tokens (20-50x cheaper than Claude/GPT)."
msgstr ""
#: includes/class-ai-translator-admin.php:544
#: includes/class-ai-translator-admin.php:612
msgid "Rare / indigenous languages"
msgstr ""
#: includes/class-ai-translator-admin.php:545
#: includes/class-ai-translator-admin.php:613
msgid "Claude Sonnet or Taskade Translate (multi-model routing)"
msgstr ""
#: includes/class-ai-translator-admin.php:546
#: includes/class-ai-translator-admin.php:614
msgid "Better coverage for uncommon language pairs."
msgstr ""
#: includes/class-ai-translator-admin.php:549
#: includes/class-ai-translator-admin.php:617
msgid "Maximum coverage (133+ languages)"
msgstr ""
#: includes/class-ai-translator-admin.php:550
#: includes/class-ai-translator-admin.php:618
msgid "Google Cloud Translation"
msgstr ""
#: includes/class-ai-translator-admin.php:551
#: includes/class-ai-translator-admin.php:619
msgid "The most complete option, though with slightly lower quality on European languages."
msgstr ""
@ -303,14 +325,14 @@ msgid "No user context. Pass --user=<id|login> so post updates carry an author a
msgstr ""
#: includes/class-ai-translator-cli.php:145
#: includes/class-ai-translator-editor-ui.php:319
#: includes/class-ai-translator-editor-ui.php:451
#: includes/class-ai-translator-translator.php:86
#: includes/class-ai-translator-editor-ui.php:427
#: includes/class-ai-translator-editor-ui.php:563
#: includes/class-ai-translator-translator.php:135
msgid "AI features are disabled for this WordPress installation."
msgstr ""
#: includes/class-ai-translator-cli.php:149
#: includes/class-ai-translator-translator.php:93
#: includes/class-ai-translator-translator.php:142
msgid "No AI provider is configured for text generation. Open Settings → AI to set one up."
msgstr ""
@ -328,73 +350,80 @@ msgstr ""
msgid "%1$d translated, %2$d failed (locale: %3$s)."
msgstr ""
#: includes/class-ai-translator-editor-ui.php:160
msgid "Sorry, you are not allowed to edit this post."
msgstr ""
#: includes/class-ai-translator-editor-ui.php:190
#: includes/class-ai-translator-editor-ui.php:229
#: includes/class-ai-translator-editor-ui.php:298
msgid "Post not found."
msgstr ""
#: includes/class-ai-translator-editor-ui.php:199
#: includes/class-ai-translator-editor-ui.php:239
#: includes/class-ai-translator-editor-ui.php:307
msgid "The selected language is not installed on this site."
msgstr ""
#: includes/class-ai-translator-editor-ui.php:221
#: includes/class-ai-translator-editor-ui.php:268
msgid "Sorry, you are not allowed to edit this post."
msgstr ""
#: includes/class-ai-translator-editor-ui.php:329
msgid "No translatable fields were selected, or the requested fields are disabled in the settings."
msgstr ""
#: includes/class-ai-translator-editor-ui.php:321
#: includes/class-ai-translator-editor-ui.php:450
#: includes/class-ai-translator-editor-ui.php:429
#: includes/class-ai-translator-editor-ui.php:562
msgid "No translation fields are enabled in settings."
msgstr ""
#: includes/class-ai-translator-editor-ui.php:323
#: includes/class-ai-translator-editor-ui.php:449
#: includes/class-ai-translator-editor-ui.php:431
#: includes/class-ai-translator-editor-ui.php:561
msgid "No languages are installed on this site."
msgstr ""
#: includes/class-ai-translator-editor-ui.php:326
#: includes/class-ai-translator-editor-ui.php:445
#: includes/class-ai-translator-editor-ui.php:434
#: includes/class-ai-translator-editor-ui.php:557
msgid "Target language"
msgstr ""
#: includes/class-ai-translator-editor-ui.php:336
#: includes/class-ai-translator-editor-ui.php:443
#: includes/class-ai-translator-editor-ui.php:444
#: includes/class-ai-translator-editor-ui.php:553
msgid "Translate"
msgstr ""
#: includes/class-ai-translator-editor-ui.php:341
#: includes/class-ai-translator-editor-ui.php:449
msgid "Save the post before translating to ensure the latest content is used. The translation replaces the current title and/or content."
msgstr ""
#: includes/class-ai-translator-editor-ui.php:444
#: includes/class-ai-translator-editor-ui.php:554
msgid "Translating…"
msgstr ""
#: includes/class-ai-translator-editor-ui.php:447
#. translators: 1: number of chunks translated so far, 2: total number of chunks.
#: includes/class-ai-translator-editor-ui.php:556
msgid "Translating… ({done}/{total})"
msgstr ""
#: includes/class-ai-translator-editor-ui.php:559
msgid "Translation applied. Review and save the post."
msgstr ""
#: includes/class-ai-translator-editor-ui.php:448
#: includes/class-ai-translator-editor-ui.php:560
msgid "Translation failed."
msgstr ""
#: includes/class-ai-translator-editor-ui.php:452
#: includes/class-ai-translator-editor-ui.php:564
msgid "You have unsaved changes. Save the post before translating to ensure the latest content is used."
msgstr ""
#: includes/class-ai-translator-translator.php:102
#: includes/class-ai-translator-translator.php:151
msgid "The target language is not installed on this site."
msgstr ""
#. translators: %s: target language name.
#: includes/class-ai-translator-translator.php:108
#: includes/class-ai-translator-translator.php:157
#, php-format
msgid "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."
msgstr ""
#: includes/class-ai-translator-translator.php:129
#: includes/class-ai-translator-translator.php:186
msgid "The AI service returned an unexpected response."
msgstr ""

View file

@ -4,7 +4,7 @@ Tags: ai, translation, multilingual, multisite, editor
Requires at least: 7.0
Tested up to: 7.1
Requires PHP: 7.4
Stable tag: 1.1.0
Stable tag: 1.2.0
License: GPL-3.0-or-later
License URI: https://www.gnu.org/licenses/gpl-3.0.txt
@ -140,6 +140,14 @@ The plugin reads the post title and content from the database, not from the edit
== Changelog ==
= 1.2.0 =
* Added configurable "Request timeout" setting (default 60 s, minimum 30 s, maximum 300 s) in site and network admin pages.
* Added parallel chunked content translation: long posts are split at Gutenberg block or paragraph boundaries and all chunks are sent to the AI concurrently, eliminating timeout errors on lengthy content.
* Added real-time progress indicator on the Translate button: "Translating… (2/5)" as each chunk resolves.
* Added new REST endpoint `POST /wp-json/ai-translator/v1/translate-text` for raw-text translation; used internally by the chunked content path.
* Changed: content translation now reads from the current editor state (unsaved changes included); title and excerpt continue to read from the saved post.
= 1.1.0 =
* Added excerpt field (`post_excerpt`) as a translatable field, with a dedicated settings toggle.

View file

@ -4,7 +4,7 @@
* Plugin URI: https://git.robotstxt.es/ROBOTSTXT/robotstxt-ai-translator
* Gitea Plugin URI: ROBOTSTXT/robotstxt-ai-translator
* Description: Translate post titles and content from the editor using the native WordPress AI client (available in WP 7.0+). Multisite-ready with global or per-site configuration.
* Version: 1.1.0
* Version: 1.2.0
* Author: ROBOTSTXT
* Author URI: https://www.robotstxt.es/
* License: GPL-3.0-or-later
@ -25,7 +25,7 @@ if ( ! defined( 'ABSPATH' ) ) {
/**
* Plugin version.
*/
define( 'AI_TRANSLATOR_VERSION', '1.1.0' );
define( 'AI_TRANSLATOR_VERSION', '1.2.0' );
/**
* Main plugin file path.
@ -94,7 +94,7 @@ add_action( 'init', 'ai_translator_load_textdomain' );
*/
function ai_translator_bootstrap() {
$settings = new AI_Translator_Settings();
$translator = new AI_Translator_Translator();
$translator = new AI_Translator_Translator( $settings );
$admin = new AI_Translator_Admin( $settings );
$editor_ui = new AI_Translator_Editor_UI( $settings, $translator );
@ -131,7 +131,7 @@ function ai_translator_dependency_notice() {
return;
}
$translator = new AI_Translator_Translator();
$translator = new AI_Translator_Translator( new AI_Translator_Settings() );
if ( $translator->is_supported() ) {
return;

View file

@ -1,20 +1,20 @@
{
"name": "AI Translator (by ROBOTSTXT)",
"slug": "robotstxt-ai-translator",
"version": "1.1.0",
"download_url": "https://git.robotstxt.es/ROBOTSTXT/robotstxt-ai-translator/releases/download/1.1.0/robotstxt-ai-translator-1.1.0.zip",
"version": "1.2.0",
"download_url": "https://git.robotstxt.es/ROBOTSTXT/robotstxt-ai-translator/releases/download/1.2.0/robotstxt-ai-translator-1.2.0.zip",
"requires": "7.0",
"requires_php": "7.4",
"tested": "7.1",
"last_updated": "2026-05-25",
"last_updated": "2026-05-26",
"author": "ROBOTSTXT",
"author_profile": "https://www.robotstxt.es/",
"homepage": "https://git.robotstxt.es/ROBOTSTXT/robotstxt-ai-translator",
"description": "Translate post titles and content from the WordPress editor using the native WordPress AI client (available in WP 7.0+). Multisite-ready with global or per-site configuration.",
"changelog": "<h3>1.1.0 - 2026-05-25</h3><ul><li><strong>Added:</strong> Excerpt translation — post_excerpt is now a translatable field with its own settings toggle.</li><li><strong>Added:</strong> MultilingualPress integration — auto-translate new connected posts at creation time (opt-in).</li><li><strong>Changed:</strong> Removed Requires Plugins: ai header — wp_ai_client_prompt() is native to WordPress 7.0; works with any configured AI provider.</li><li><strong>Changed:</strong> Admin notice now fires when no text-generation provider is configured.</li></ul><h3>1.0.0 - 2026-05-23</h3><ul><li><strong>Added:</strong> Block editor sidebar and Classic editor metabox with language selector and Translate button.</li><li><strong>Added:</strong> REST endpoint POST /wp-json/ai-translator/v1/translate.</li><li><strong>Added:</strong> WP-CLI command wp ai-translator translate for single-post and bulk translation.</li><li><strong>Added:</strong> Network admin settings page with global / per-site configuration mode.</li><li><strong>Added:</strong> Site admin settings page with title and content translation toggles.</li><li><strong>Added:</strong> Model recommendations table in the settings pages.</li></ul>",
"changelog": "<h3>1.2.0 - 2026-05-26</h3><ul><li><strong>Added:</strong> Configurable request timeout — new \"Request timeout\" setting (default 60 s, min 30 s, max 300 s) controls how long the server waits for an AI response before timing out.</li><li><strong>Added:</strong> Parallel chunked content translation — long post content is automatically split at block (Gutenberg) or paragraph (Classic editor) boundaries and translated in parallel, eliminating timeout errors on lengthy posts.</li><li><strong>Added:</strong> Progress indicator — the Translate button shows \"Translating… (2/5)\" while chunks are in flight.</li><li><strong>Added:</strong> New REST endpoint POST /wp-json/ai-translator/v1/translate-text for raw-text translation; used internally by the chunked content path.</li></ul><h3>1.1.0 - 2026-05-25</h3><ul><li><strong>Added:</strong> Excerpt translation — post_excerpt is now a translatable field with its own settings toggle.</li><li><strong>Added:</strong> MultilingualPress integration — auto-translate new connected posts at creation time (opt-in).</li><li><strong>Changed:</strong> Removed Requires Plugins: ai header — wp_ai_client_prompt() is native to WordPress 7.0; works with any configured AI provider.</li><li><strong>Changed:</strong> Admin notice now fires when no text-generation provider is configured.</li></ul><h3>1.0.0 - 2026-05-23</h3><ul><li><strong>Added:</strong> Block editor sidebar and Classic editor metabox with language selector and Translate button.</li><li><strong>Added:</strong> REST endpoint POST /wp-json/ai-translator/v1/translate.</li><li><strong>Added:</strong> WP-CLI command wp ai-translator translate for single-post and bulk translation.</li><li><strong>Added:</strong> Network admin settings page with global / per-site configuration mode.</li><li><strong>Added:</strong> Site admin settings page with title and content translation toggles.</li><li><strong>Added:</strong> Model recommendations table in the settings pages.</li></ul>",
"sections": {
"description": "Translate post titles and content from the WordPress editor using the native WordPress AI client (available in WP 7.0+). Multisite-ready with global or per-site configuration.",
"changelog": "<h3>1.1.0 - 2026-05-25</h3><ul><li><strong>Added:</strong> Excerpt translation — post_excerpt is now a translatable field with its own settings toggle.</li><li><strong>Added:</strong> MultilingualPress integration — auto-translate new connected posts at creation time (opt-in).</li><li><strong>Changed:</strong> Removed Requires Plugins: ai header — wp_ai_client_prompt() is native to WordPress 7.0; works with any configured AI provider.</li><li><strong>Changed:</strong> Admin notice now fires when no text-generation provider is configured.</li></ul><h3>1.0.0 - 2026-05-23</h3><ul><li><strong>Added:</strong> Block editor sidebar and Classic editor metabox with language selector and Translate button.</li><li><strong>Added:</strong> REST endpoint POST /wp-json/ai-translator/v1/translate.</li><li><strong>Added:</strong> WP-CLI command wp ai-translator translate for single-post and bulk translation.</li><li><strong>Added:</strong> Network admin settings page with global / per-site configuration mode.</li><li><strong>Added:</strong> Site admin settings page with title and content translation toggles.</li><li><strong>Added:</strong> Model recommendations table in the settings pages.</li></ul>"
"changelog": "<h3>1.2.0 - 2026-05-26</h3><ul><li><strong>Added:</strong> Configurable request timeout — new \"Request timeout\" setting (default 60 s, min 30 s, max 300 s) controls how long the server waits for an AI response before timing out.</li><li><strong>Added:</strong> Parallel chunked content translation — long post content is automatically split at block (Gutenberg) or paragraph (Classic editor) boundaries and translated in parallel, eliminating timeout errors on lengthy posts.</li><li><strong>Added:</strong> Progress indicator — the Translate button shows \"Translating… (2/5)\" while chunks are in flight.</li><li><strong>Added:</strong> New REST endpoint POST /wp-json/ai-translator/v1/translate-text for raw-text translation; used internally by the chunked content path.</li></ul><h3>1.1.0 - 2026-05-25</h3><ul><li><strong>Added:</strong> Excerpt translation — post_excerpt is now a translatable field with its own settings toggle.</li><li><strong>Added:</strong> MultilingualPress integration — auto-translate new connected posts at creation time (opt-in).</li><li><strong>Changed:</strong> Removed Requires Plugins: ai header — wp_ai_client_prompt() is native to WordPress 7.0; works with any configured AI provider.</li><li><strong>Changed:</strong> Admin notice now fires when no text-generation provider is configured.</li></ul><h3>1.0.0 - 2026-05-23</h3><ul><li><strong>Added:</strong> Block editor sidebar and Classic editor metabox with language selector and Translate button.</li><li><strong>Added:</strong> REST endpoint POST /wp-json/ai-translator/v1/translate.</li><li><strong>Added:</strong> WP-CLI command wp ai-translator translate for single-post and bulk translation.</li><li><strong>Added:</strong> Network admin settings page with global / per-site configuration mode.</li><li><strong>Added:</strong> Site admin settings page with title and content translation toggles.</li><li><strong>Added:</strong> Model recommendations table in the settings pages.</li></ul>"
},
"banners": {
"low": "",