robotstxt-ai-translator/assets/js/editor.js
2026-05-28 07:22:36 +00:00

703 lines
21 KiB
JavaScript

/**
* AI Translator (by ROBOTSTXT) — editor UI.
*
* Adds a translation panel to the Block editor sidebar and a handler for the
* 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
*/
( function () {
'use strict';
function warn( message ) {
if ( window.console && typeof window.console.warn === 'function' ) {
window.console.warn( '[AI Translator] ' + message );
}
}
var data = window.aiTranslatorData;
if ( ! data ) {
warn( 'window.aiTranslatorData is missing; aborting.' );
return;
}
var apiFetch = window.wp && window.wp.apiFetch ? window.wp.apiFetch : null;
var labels = data.i18n || {};
// ── Chunking helpers ──────────────────────────────────────────────────────
/**
* Splits Gutenberg block HTML into chunks at block boundaries.
*
* 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>}
*/
function requestTranslation( fields, targetLocale ) {
if ( ! apiFetch ) {
return Promise.reject( new Error( labels.genericError || 'Translation failed.' ) );
}
return apiFetch( {
path: '/' + data.restNamespace + data.restRoute,
method: 'POST',
data: {
post_id: data.postId,
fields: fields,
target_locale: targetLocale,
},
} );
}
function resolveMetaFields( wantTitle, wantExcerpt ) {
var fields = [];
if ( wantTitle && data.settings.translateTitle ) {
fields.push( 'title' );
}
if ( wantExcerpt && data.settings.translateExcerpt ) {
fields.push( 'excerpt' );
}
return fields;
}
function describeError( error ) {
if ( error && typeof error.message === 'string' && error.message ) {
return error.message;
}
return labels.genericError || 'Translation failed.';
}
/**
* Applies translated content to the Block editor.
*
* @param {string} content Serialised block HTML.
*/
function applyContentToBlockEditor( content ) {
var wp = window.wp || {};
if ( ! wp.data || ! wp.data.dispatch ) {
return;
}
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' );
if ( textarea ) {
textarea.value = content;
}
if ( window.tinymce && typeof window.tinymce.get === 'function' ) {
var editor = window.tinymce.get( 'content' );
if ( editor && typeof editor.setContent === 'function' ) {
editor.setContent( content );
}
}
}
/**
* 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' );
if ( ! container ) {
return;
}
var button = document.getElementById( 'ai-translator-classic-button' );
var select = document.getElementById( 'ai-translator-locale' );
var status = document.getElementById( 'ai-translator-classic-status' );
if ( ! button || ! select ) {
return;
}
function setStatus( text, kind ) {
if ( ! status ) {
return;
}
status.textContent = text;
status.className = 'ai-translator-status' + ( kind ? ' ai-translator-status--' + kind : '' );
}
button.addEventListener( 'click', function () {
var locale = select.value;
if ( ! locale ) {
return;
}
var wantContent = data.settings.translateContent;
var metaFields = resolveMetaFields(
data.settings.translateTitle,
data.settings.translateExcerpt
);
if ( metaFields.length === 0 && ! wantContent ) {
setStatus( labels.noFields || '', 'warning' );
return;
}
button.disabled = true;
button.dataset.busy = '1';
setStatus( labels.translating || 'Translating…', 'busy' );
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 ) {
setStatus( describeError( error ), 'error' );
} )
.then( function () {
button.disabled = false;
button.dataset.busy = '0';
} );
} );
}
// ── Block editor integration (PluginSidebar) ──────────────────────────────
function initBlockEditor() {
var wp = window.wp || {};
if ( ! wp.plugins || ! wp.element || ! wp.components || ! wp.data || ! wp.editor || ! wp.editor.PluginSidebar ) {
warn( 'Block editor APIs not available; skipping sidebar registration.' );
return;
}
var el = wp.element.createElement;
var Fragment = wp.element.Fragment;
var useState = wp.element.useState;
var registerP = wp.plugins.registerPlugin;
var sidebar = wp.editor.PluginSidebar;
var menuItem = wp.editor.PluginSidebarMoreMenuItem;
var components = wp.components;
var dataStore = wp.data;
function applyNoticeSnackbar( status, message ) {
if ( ! dataStore || ! dataStore.dispatch || ! message ) {
return;
}
var noticesStore = dataStore.dispatch( 'core/notices' );
if ( ! noticesStore || typeof noticesStore.createNotice !== 'function' ) {
return;
}
noticesStore.createNotice(
status,
message,
{
type: 'snackbar',
isDismissible: true,
}
);
}
function Panel() {
var localeState = useState( ( data.languages && data.languages[0] ) ? data.languages[0].locale : '' );
var locale = localeState[0];
var setLocale = localeState[1];
var busyState = useState( false );
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];
var titleState = useState( !! data.settings.translateTitle );
var translateTitle = titleState[0];
var setTranslateTitle = titleState[1];
var contentState = useState( !! data.settings.translateContent );
var translateContent = contentState[0];
var setTranslateContent = contentState[1];
var excerptState = useState( !! data.settings.translateExcerpt );
var translateExcerpt = excerptState[0];
var setTranslateExcerpt = excerptState[1];
var coreEditor = dataStore.select( 'core/editor' );
var isDirty = coreEditor && typeof coreEditor.isEditedPostDirty === 'function' ? coreEditor.isEditedPostDirty() : false;
if ( ! data.available ) {
return el( 'div', { className: 'ai-translator-sidebar' },
el( components.Notice, { status: 'warning', isDismissible: false }, labels.aiUnavailable || '' )
);
}
if ( ! data.settings.translateTitle && ! data.settings.translateContent && ! data.settings.translateExcerpt ) {
return el( 'div', { className: 'ai-translator-sidebar' },
el( components.Notice, { status: 'info', isDismissible: false }, labels.noFields || '' )
);
}
if ( ! data.languages || data.languages.length === 0 ) {
return el( 'div', { className: 'ai-translator-sidebar' },
el( components.Notice, { status: 'info', isDismissible: false }, labels.noLanguages || '' )
);
}
var options = data.languages.map( function ( language ) {
return { value: language.locale, label: language.name };
} );
function runTranslate() {
var wantTitle = translateTitle && data.settings.translateTitle;
var wantContent = translateContent && data.settings.translateContent;
var wantExcerpt = translateExcerpt && data.settings.translateExcerpt;
if ( ! wantTitle && ! wantContent && ! wantExcerpt ) {
setNotice( { status: 'warning', message: labels.noFields || '' } );
return;
}
setBusy( true );
setProgress( null );
setNotice( null );
applyNoticeSnackbar( 'info', labels.translating || 'Translating…' );
// 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 () {
setBusy( false );
} );
}
// 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 ) {
children.push( el( components.Notice, {
status: 'warning',
isDismissible: false,
key: 'dirty',
}, labels.saveBeforeWarn || '' ) );
}
if ( notice ) {
children.push( el( components.Notice, {
status: notice.status,
isDismissible: true,
onRemove: function () {
setNotice( null );
},
key: 'notice',
}, notice.message ) );
}
children.push( el( components.SelectControl, {
key: 'locale',
label: labels.targetLanguage || '',
value: locale,
options: options,
onChange: function ( value ) {
setLocale( value );
},
} ) );
if ( data.settings.translateTitle ) {
children.push( el( components.CheckboxControl, {
key: 'title',
label: labels.translateTitle || '',
checked: translateTitle,
onChange: function ( value ) {
setTranslateTitle( !! value );
},
} ) );
}
if ( data.settings.translateContent ) {
children.push( el( components.CheckboxControl, {
key: 'content',
label: labels.translateContent || '',
checked: translateContent,
onChange: function ( value ) {
setTranslateContent( !! value );
},
} ) );
}
if ( data.settings.translateExcerpt ) {
children.push( el( components.CheckboxControl, {
key: 'excerpt',
label: labels.translateExcerpt || '',
checked: translateExcerpt,
onChange: function ( value ) {
setTranslateExcerpt( !! value );
},
} ) );
}
children.push( el( components.Button, {
key: 'submit',
variant: 'primary',
disabled: busy || ! locale,
isBusy: busy,
onClick: runTranslate,
}, buttonLabel ) );
return el( 'div', { className: 'ai-translator-sidebar' }, children );
}
registerP( 'ai-translator', {
render: function () {
return el( Fragment, null,
menuItem ? el( menuItem, { target: 'ai-translator', icon: 'translation' }, labels.panelTitle || '' ) : null,
el( sidebar, { name: 'ai-translator', title: labels.panelTitle || '', icon: 'translation' },
el( components.PanelBody, {}, el( Panel ) )
)
);
},
} );
}
function start() {
if ( data.context === 'block' ) {
initBlockEditor();
} else {
initClassicEditor();
}
}
if ( document.readyState === 'loading' ) {
document.addEventListener( 'DOMContentLoaded', start );
} else {
start();
}
} )();