This commit is contained in:
Javier Casares 2026-05-25 17:12:22 +00:00
commit 02cd01e862
20 changed files with 1365 additions and 646 deletions

View file

@ -221,16 +221,22 @@ if ( ! class_exists( 'AI_Translator_Admin' ) ) {
<label for="ai-translator-translate-content">
<input type="checkbox" id="ai-translator-translate-content" name="ai_translator_settings[translate_content]" value="1" <?php checked( $current['translate_content'] ); ?> />
<?php echo esc_html__( 'Translate content', 'robotstxt-ai-translator' ); ?>
</label><br />
<label for="ai-translator-translate-excerpt">
<input type="checkbox" id="ai-translator-translate-excerpt" name="ai_translator_settings[translate_excerpt]" value="1" <?php checked( $current['translate_excerpt'] ); ?> />
<?php echo esc_html__( 'Translate excerpt', 'robotstxt-ai-translator' ); ?>
</label>
<?php if ( is_multisite() ) : ?>
<p class="description">
<?php
printf(
/* translators: 1: title default, 2: content default. */
esc_html__( 'Network defaults: title %1$s, content %2$s.', 'robotstxt-ai-translator' ),
/* translators: 1: title default, 2: content default, 3: excerpt default. */
esc_html__( 'Network defaults: title %1$s, content %2$s, excerpt %3$s.', 'robotstxt-ai-translator' ),
isset( $defaults['translate_title'] ) && $defaults['translate_title'] ? esc_html__( 'enabled', 'robotstxt-ai-translator' ) : esc_html__( 'disabled', 'robotstxt-ai-translator' ),
isset( $defaults['translate_content'] ) && $defaults['translate_content'] ? esc_html__( 'enabled', 'robotstxt-ai-translator' ) : esc_html__( 'disabled', 'robotstxt-ai-translator' )
isset( $defaults['translate_content'] ) && $defaults['translate_content'] ? esc_html__( 'enabled', 'robotstxt-ai-translator' ) : esc_html__( 'disabled', 'robotstxt-ai-translator' ),
isset( $defaults['translate_excerpt'] ) && $defaults['translate_excerpt'] ? esc_html__( 'enabled', 'robotstxt-ai-translator' ) : esc_html__( 'disabled', 'robotstxt-ai-translator' )
);
?>
</p>
@ -238,6 +244,26 @@ if ( ! class_exists( 'AI_Translator_Admin' ) ) {
</fieldset>
</td>
</tr>
<?php if ( class_exists( '\Inpsyde\MultilingualPress\TranslationUi\Post\MetaboxAction' ) ) : ?>
<tr>
<th scope="row"><?php echo esc_html__( 'MultilingualPress', 'robotstxt-ai-translator' ); ?></th>
<td>
<fieldset>
<legend class="screen-reader-text"><?php echo esc_html__( 'MultilingualPress', 'robotstxt-ai-translator' ); ?></legend>
<label for="ai-translator-auto-translate-mlp">
<input type="checkbox" id="ai-translator-auto-translate-mlp" name="ai_translator_settings[auto_translate_on_mlp_create]" value="1" <?php checked( $current['auto_translate_on_mlp_create'] ); ?> />
<?php echo esc_html__( 'Auto-translate new connected posts', 'robotstxt-ai-translator' ); ?>
</label>
<p class="description">
<?php echo esc_html__( 'When MultilingualPress creates a new connected post, automatically translate its enabled fields into the target site\'s language. The translation runs synchronously during save.', 'robotstxt-ai-translator' ); ?>
</p>
</fieldset>
</td>
</tr>
<?php endif; ?>
</tbody>
</table>
@ -315,6 +341,11 @@ if ( ! class_exists( 'AI_Translator_Admin' ) ) {
<label for="ai-translator-net-translate-content">
<input type="checkbox" id="ai-translator-net-translate-content" name="ai_translator_settings[translate_content]" value="1" <?php checked( $network['translate_content'] ); ?> />
<?php echo esc_html__( 'Translate content', 'robotstxt-ai-translator' ); ?>
</label><br />
<label for="ai-translator-net-translate-excerpt">
<input type="checkbox" id="ai-translator-net-translate-excerpt" name="ai_translator_settings[translate_excerpt]" value="1" <?php checked( $network['translate_excerpt'] ); ?> />
<?php echo esc_html__( 'Translate excerpt', 'robotstxt-ai-translator' ); ?>
</label>
<p class="description">
@ -323,6 +354,26 @@ if ( ! class_exists( 'AI_Translator_Admin' ) ) {
</fieldset>
</td>
</tr>
<?php if ( class_exists( '\Inpsyde\MultilingualPress\TranslationUi\Post\MetaboxAction' ) ) : ?>
<tr>
<th scope="row"><?php echo esc_html__( 'MultilingualPress', 'robotstxt-ai-translator' ); ?></th>
<td>
<fieldset>
<legend class="screen-reader-text"><?php echo esc_html__( 'MultilingualPress', 'robotstxt-ai-translator' ); ?></legend>
<label for="ai-translator-net-auto-translate-mlp">
<input type="checkbox" id="ai-translator-net-auto-translate-mlp" name="ai_translator_settings[auto_translate_on_mlp_create]" value="1" <?php checked( $network['auto_translate_on_mlp_create'] ); ?> />
<?php echo esc_html__( 'Auto-translate new connected posts', 'robotstxt-ai-translator' ); ?>
</label>
<p class="description">
<?php echo esc_html__( 'When MultilingualPress creates a new connected post, automatically translate its enabled fields into the target site\'s language. The translation runs synchronously during save.', 'robotstxt-ai-translator' ); ?>
</p>
</fieldset>
</td>
</tr>
<?php endif; ?>
</tbody>
</table>
@ -514,6 +565,11 @@ if ( ! class_exists( 'AI_Translator_Admin' ) ) {
/**
* Sanitises a raw settings array into a strict boolean 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.
*
* @since 1.0.0
*
* @param array<string,mixed> $raw Raw input from the form.
@ -522,8 +578,10 @@ if ( ! class_exists( 'AI_Translator_Admin' ) ) {
*/
private function sanitize_settings( array $raw ) {
return array(
'translate_title' => ! empty( $raw['translate_title'] ),
'translate_content' => ! empty( $raw['translate_content'] ),
'translate_title' => ! empty( $raw['translate_title'] ),
'translate_content' => ! empty( $raw['translate_content'] ),
'translate_excerpt' => ! empty( $raw['translate_excerpt'] ),
'auto_translate_on_mlp_create' => ! empty( $raw['auto_translate_on_mlp_create'] ),
);
}
}

View file

@ -124,19 +124,8 @@ if ( ! class_exists( 'AI_Translator_CLI' ) ) {
WP_CLI::error( __( 'The --post-id and --post-type flags are mutually exclusive.', 'robotstxt-ai-translator' ) );
}
$current_user = wp_get_current_user();
if ( ! $current_user || 0 === (int) $current_user->ID ) {
WP_CLI::warning( __( 'No user context. Pass --user=<id|login> so post updates carry an author and capability checks apply.', 'robotstxt-ai-translator' ) );
}
if ( ! $dry_run && ! $this->translator->is_available() ) {
WP_CLI::error( __( 'The WordPress AI plugin is not active.', 'robotstxt-ai-translator' ) );
}
if ( ! $dry_run && ! $this->translator->is_supported() ) {
WP_CLI::error( __( 'The WordPress AI plugin has no provider configured for text generation.', 'robotstxt-ai-translator' ) );
}
// Validate locale before AI checks so the user sees the right error when
// both the locale is missing and the AI provider is unconfigured.
if ( ! in_array( $locale, $this->translator->get_installed_locales(), true ) ) {
WP_CLI::error(
sprintf(
@ -147,9 +136,22 @@ if ( ! class_exists( 'AI_Translator_CLI' ) ) {
);
}
$current_user = wp_get_current_user();
if ( ! $current_user || 0 === (int) $current_user->ID ) {
WP_CLI::warning( __( 'No user context. Pass --user=<id|login> so post updates carry an author and capability checks apply.', 'robotstxt-ai-translator' ) );
}
if ( ! $dry_run && ! $this->translator->is_available() ) {
WP_CLI::error( __( 'AI features are disabled for this WordPress installation.', 'robotstxt-ai-translator' ) );
}
if ( ! $dry_run && ! $this->translator->is_supported() ) {
WP_CLI::error( __( 'No AI provider is configured for text generation. Open Settings → AI to set one up.', 'robotstxt-ai-translator' ) );
}
$settings = $this->settings->get_settings();
if ( empty( $settings['translate_title'] ) && empty( $settings['translate_content'] ) ) {
if ( empty( $settings['translate_title'] ) && empty( $settings['translate_content'] ) && empty( $settings['translate_excerpt'] ) ) {
WP_CLI::error( __( 'No translation fields are enabled in the plugin settings.', 'robotstxt-ai-translator' ) );
}
@ -212,6 +214,16 @@ if ( ! class_exists( 'AI_Translator_CLI' ) ) {
$update['post_content'] = $translated_content;
}
if ( ! empty( $settings['translate_excerpt'] ) && post_type_supports( $post->post_type, 'excerpt' ) ) {
$translated_excerpt = $this->translator->translate( $post->post_excerpt, $locale );
if ( is_wp_error( $translated_excerpt ) ) {
WP_CLI::warning( sprintf( '#%d excerpt: %s', $post->ID, $translated_excerpt->get_error_message() ) );
++$failures;
continue;
}
$update['post_excerpt'] = $translated_excerpt;
}
$result = wp_update_post( $update, true );
if ( is_wp_error( $result ) ) {

View file

@ -28,7 +28,7 @@ if ( ! class_exists( 'AI_Translator_Editor_UI' ) ) {
* @since 1.0.0
* @var array<int,string>
*/
const ALLOWED_FIELDS = array( 'title', 'content' );
const ALLOWED_FIELDS = array( 'title', 'content', 'excerpt' );
/**
* Settings handler.
@ -210,6 +210,10 @@ if ( ! class_exists( 'AI_Translator_Editor_UI' ) ) {
if ( in_array( 'content', $fields, true ) && ! empty( $site_settings['translate_content'] ) ) {
$allowed[] = 'content';
}
if ( in_array( 'excerpt', $fields, true ) && ! empty( $site_settings['translate_excerpt'] )
&& post_type_supports( $post->post_type, 'excerpt' ) ) {
$allowed[] = 'excerpt';
}
if ( empty( $allowed ) ) {
return new WP_Error(
@ -237,6 +241,14 @@ if ( ! class_exists( 'AI_Translator_Editor_UI' ) ) {
$response['content'] = $translated;
}
if ( in_array( 'excerpt', $allowed, true ) ) {
$translated = $this->translator->translate( $post->post_excerpt, $target_locale );
if ( is_wp_error( $translated ) ) {
return $this->error_with_status( $translated, 502 );
}
$response['excerpt'] = $translated;
}
return new WP_REST_Response( $response, 200 );
}
@ -304,8 +316,8 @@ if ( ! class_exists( 'AI_Translator_Editor_UI' ) ) {
?>
<div id="ai-translator-classic" class="ai-translator-classic" data-post-id="<?php echo esc_attr( (string) $post->ID ); ?>">
<?php if ( ! $this->translator->is_available() ) : ?>
<p><?php echo esc_html__( 'The WordPress AI plugin is not active. Translation is unavailable.', 'robotstxt-ai-translator' ); ?></p>
<?php elseif ( empty( $settings['translate_title'] ) && empty( $settings['translate_content'] ) ) : ?>
<p><?php echo esc_html__( 'AI features are disabled for this WordPress installation.', 'robotstxt-ai-translator' ); ?></p>
<?php elseif ( empty( $settings['translate_title'] ) && empty( $settings['translate_content'] ) && empty( $settings['translate_excerpt'] ) ) : ?>
<p><?php echo esc_html__( 'No translation fields are enabled in settings.', 'robotstxt-ai-translator' ); ?></p>
<?php elseif ( empty( $languages ) ) : ?>
<p><?php echo esc_html__( 'No languages are installed on this site.', 'robotstxt-ai-translator' ); ?></p>
@ -425,6 +437,7 @@ if ( ! class_exists( 'AI_Translator_Editor_UI' ) ) {
'settings' => array(
'translateTitle' => ! empty( $settings['translate_title'] ),
'translateContent' => ! empty( $settings['translate_content'] ),
'translateExcerpt' => ! empty( $settings['translate_excerpt'] ),
),
'i18n' => array(
'translate' => __( 'Translate', 'robotstxt-ai-translator' ),
@ -435,10 +448,11 @@ if ( ! class_exists( 'AI_Translator_Editor_UI' ) ) {
'genericError' => __( 'Translation failed.', 'robotstxt-ai-translator' ),
'noLanguages' => __( 'No languages are installed on this site.', 'robotstxt-ai-translator' ),
'noFields' => __( 'No translation fields are enabled in settings.', 'robotstxt-ai-translator' ),
'aiUnavailable' => __( 'The WordPress AI plugin is not active.', 'robotstxt-ai-translator' ),
'aiUnavailable' => __( 'AI features are disabled for this WordPress installation.', 'robotstxt-ai-translator' ),
'saveBeforeWarn' => __( 'You have unsaved changes. Save the post before translating to ensure the latest content is used.', 'robotstxt-ai-translator' ),
'translateTitle' => __( 'Translate title', 'robotstxt-ai-translator' ),
'translateContent' => __( 'Translate content', 'robotstxt-ai-translator' ),
'translateExcerpt' => __( 'Translate excerpt', 'robotstxt-ai-translator' ),
),
);
@ -466,9 +480,9 @@ if ( ! class_exists( 'AI_Translator_Editor_UI' ) ) {
return (int) $post->ID;
}
if ( isset( $_GET['post'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$post_get = wp_unslash( $_GET['post'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
return is_scalar( $post_get ) ? absint( $post_get ) : 0;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( isset( $_GET['post'] ) && is_scalar( $_GET['post'] ) ) {
return absint( (string) $_GET['post'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
}
return 0;

View file

@ -0,0 +1,174 @@
<?php
/**
* MultilingualPress integration for AI Translator.
*
* When MultilingualPress creates a new connected post (i.e. the editor saves
* a source post and MLP syncs it to another site for the first time), this
* class translates the source post's fields into the target site's language
* and writes the result back to the newly created remote post.
*
* The hook `multilingualpress.metabox_after_update_remote_post` fires while
* WordPress is already switched to the remote site, so wp_update_post() can
* be called directly without an additional switch_to_blog().
*
* This integration only activates when:
* 1. MultilingualPress is active (class_exists guard in bootstrap).
* 2. "Auto-translate on MLP sync" is enabled in the plugin settings.
* 3. An AI provider is configured for text generation.
*
* @package ROBOTSTXT\AI_Translator
* @since 1.1.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'AI_Translator_MLP_Integration' ) ) {
/**
* Hooks into MultilingualPress post synchronization to auto-translate
* newly connected posts.
*
* @since 1.1.0
*/
class AI_Translator_MLP_Integration {
/**
* Settings handler.
*
* @since 1.1.0
* @var AI_Translator_Settings
*/
private $settings;
/**
* Translator handler.
*
* @since 1.1.0
* @var AI_Translator_Translator
*/
private $translator;
/**
* Constructor.
*
* @since 1.1.0
*
* @param AI_Translator_Settings $settings Settings handler.
* @param AI_Translator_Translator $translator Translator handler.
*/
public function __construct( AI_Translator_Settings $settings, AI_Translator_Translator $translator ) {
$this->settings = $settings;
$this->translator = $translator;
}
/**
* Registers WordPress hooks.
*
* @since 1.1.0
*
* @return void
*/
public function register() {
add_action(
'multilingualpress.metabox_after_update_remote_post',
array( $this, 'on_mlp_after_update_remote_post' ),
10,
3
);
}
/**
* Translates the source post and updates the newly created remote post.
*
* Fires while WordPress is switched to the remote site, so
* wp_update_post() can be called directly.
*
* Only acts when:
* - $operation is 'new' (first connection, not an update).
* - The "auto_translate_on_mlp_create" setting is enabled.
* - An AI provider is configured for text generation.
*
* @since 1.1.0
*
* @param \Inpsyde\MultilingualPress\TranslationUi\Post\RelationshipContext $context MLP relationship context.
* @param array<string,scalar|null> $post Post data used to create the remote post.
* @param string $operation 'new' = first connection, 'leave' = update.
*
* @return void
*/
public function on_mlp_after_update_remote_post( $context, $post, $operation ) {
unset( $post );
if ( 'new' !== $operation ) {
return;
}
$settings = $this->settings->get_settings();
if ( empty( $settings['auto_translate_on_mlp_create'] ) ) {
return;
}
if ( ! $this->translator->is_supported() ) {
return;
}
// We are already on the remote site (MLP switched before firing this hook).
// get_blog_option() with the explicit site ID is safe and avoids relying
// on locale globals that may not be updated after switch_to_blog().
$raw_locale = get_blog_option( $context->remoteSiteId(), 'WPLANG' );
$target_locale = is_string( $raw_locale ) && '' !== $raw_locale ? $raw_locale : 'en_US';
// sourcePost() performs switch_to_blog internally and restores afterward.
$source_post = $context->sourcePost();
if ( ! $source_post instanceof WP_Post ) {
return;
}
$remote_post_id = $context->remotePostId();
$update = array( 'ID' => $remote_post_id );
$has_changes = false;
// ── Translate title ──────────────────────────────────────────────
if ( ! empty( $settings['translate_title'] ) && '' !== trim( $source_post->post_title ) ) {
$translated = $this->translator->translate( $source_post->post_title, $target_locale );
if ( ! is_wp_error( $translated ) && '' !== $translated ) {
$update['post_title'] = $translated;
$has_changes = true;
}
}
// ── Translate content ────────────────────────────────────────────
if ( ! empty( $settings['translate_content'] ) && '' !== trim( $source_post->post_content ) ) {
$translated = $this->translator->translate( $source_post->post_content, $target_locale );
if ( ! is_wp_error( $translated ) && '' !== $translated ) {
$update['post_content'] = $translated;
$has_changes = true;
}
}
// ── Translate excerpt ────────────────────────────────────────────
if ( ! empty( $settings['translate_excerpt'] )
&& '' !== trim( $source_post->post_excerpt )
&& post_type_supports( $source_post->post_type, 'excerpt' )
) {
$translated = $this->translator->translate( $source_post->post_excerpt, $target_locale );
if ( ! is_wp_error( $translated ) && '' !== $translated ) {
$update['post_excerpt'] = $translated;
$has_changes = true;
}
}
if ( $has_changes ) {
// Already on the remote site — no switch_to_blog() needed.
wp_update_post( wp_slash( $update ) );
}
}
}
}

View file

@ -55,8 +55,10 @@ if ( ! class_exists( 'AI_Translator_Settings' ) ) {
* @var array<string,bool>
*/
const DEFAULTS = array(
'translate_title' => true,
'translate_content' => true,
'translate_title' => true,
'translate_content' => true,
'translate_excerpt' => true,
'auto_translate_on_mlp_create' => false,
);
/**

View file

@ -23,14 +23,18 @@ if ( ! class_exists( 'AI_Translator_Translator' ) ) {
class AI_Translator_Translator {
/**
* Indicates whether the WordPress AI client is available.
* Indicates whether AI features are enabled for this WordPress installation.
*
* `wp_ai_client_prompt()` is a native WordPress 7.0 function the API is always
* present. This method checks `wp_supports_ai()`, which returns false only when the
* `WP_AI_SUPPORT` constant is set to false or the `wp_supports_ai` filter disables it.
*
* @since 1.0.0
*
* @return bool True if the dependency is active.
* @return bool True when WordPress AI is enabled for this installation.
*/
public function is_available() {
return function_exists( 'wp_ai_client_prompt' );
return wp_supports_ai();
}
/**
@ -78,15 +82,15 @@ if ( ! class_exists( 'AI_Translator_Translator' ) ) {
if ( ! $this->is_available() ) {
return new WP_Error(
'ai_translator_unavailable',
__( 'The WordPress AI client is not available. Install and activate the WordPress AI plugin.', 'robotstxt-ai-translator' )
'ai_translator_disabled',
__( 'AI features are disabled for this WordPress installation.', 'robotstxt-ai-translator' )
);
}
if ( ! $this->is_supported() ) {
return new WP_Error(
'ai_translator_unsupported',
__( 'The configured AI providers cannot serve text generation requests. Open the WordPress AI settings to configure a provider.', 'robotstxt-ai-translator' )
__( 'No AI provider is configured for text generation. Open Settings → AI to set one up.', 'robotstxt-ai-translator' )
);
}