v1.2.0
This commit is contained in:
parent
02cd01e862
commit
1ccc13da01
14 changed files with 845 additions and 197 deletions
|
|
@ -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<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>} Promise resolving to the translated values.
|
||||
* @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 ) {
|
||||
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.content !== undefined ) {
|
||||
if ( wp.blocks && typeof wp.blocks.parse === 'function' ) {
|
||||
var blocks = wp.blocks.parse( response.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 } );
|
||||
}
|
||||
function applyContentToBlockEditor( content ) {
|
||||
var wp = window.wp || {};
|
||||
if ( ! wp.data || ! wp.data.dispatch ) {
|
||||
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;
|
||||
if ( wp.blocks && typeof wp.blocks.parse === 'function' ) {
|
||||
var blocks = wp.blocks.parse( content );
|
||||
var blockEd = wp.data.dispatch( 'core/block-editor' );
|
||||
if ( blockEd && typeof blockEd.resetBlocks === 'function' ) {
|
||||
blockEd.resetBlocks( blocks );
|
||||
return;
|
||||
}
|
||||
}
|
||||
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,23 +368,55 @@
|
|||
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;
|
||||
}
|
||||
|
||||
button.disabled = true;
|
||||
button.disabled = true;
|
||||
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 );
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue