This commit is contained in:
Javier Casares 2026-05-23 10:13:56 +00:00
commit a157afe625
21 changed files with 4623 additions and 0 deletions

View file

@ -0,0 +1,530 @@
<?php
/**
* Admin pages and form handling for the AI Translator plugin.
*
* @package ROBOTSTXT\AI_Translator
* @since 1.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'AI_Translator_Admin' ) ) {
/**
* Registers the settings pages and handles form submissions.
*
* @since 1.0.0
*/
class AI_Translator_Admin {
/**
* Menu slug used in single-site and Multisite per-site admin.
*
* @since 1.0.0
* @var string
*/
const SITE_MENU_SLUG = 'ai-translator-settings';
/**
* Menu slug used in Multisite network admin.
*
* @since 1.0.0
* @var string
*/
const NETWORK_MENU_SLUG = 'ai-translator-network-settings';
/**
* Nonce action for the site form.
*
* @since 1.0.0
* @var string
*/
const NONCE_SITE = 'ai_translator_site_save';
/**
* Nonce action for the network form.
*
* @since 1.0.0
* @var string
*/
const NONCE_NETWORK = 'ai_translator_network_save';
/**
* Settings handler.
*
* @since 1.0.0
* @var AI_Translator_Settings
*/
private $settings;
/**
* Constructor.
*
* @since 1.0.0
*
* @param AI_Translator_Settings $settings Settings handler.
*/
public function __construct( AI_Translator_Settings $settings ) {
$this->settings = $settings;
}
/**
* Registers WordPress hooks.
*
* @since 1.0.0
*
* @return void
*/
public function register() {
add_action( 'admin_menu', array( $this, 'register_site_menu' ) );
add_action( 'admin_post_ai_translator_save_site', array( $this, 'handle_site_save' ) );
if ( is_multisite() ) {
add_action( 'network_admin_menu', array( $this, 'register_network_menu' ) );
add_action( 'network_admin_edit_ai_translator_save_network', array( $this, 'handle_network_save' ) );
}
add_filter( 'plugin_action_links_' . AI_TRANSLATOR_BASENAME, array( $this, 'add_settings_link' ) );
if ( is_multisite() ) {
add_filter( 'network_admin_plugin_action_links_' . AI_TRANSLATOR_BASENAME, array( $this, 'add_network_settings_link' ) );
}
}
/**
* Registers the site-level settings page.
*
* Hidden in Multisite when the network mode is 'global', because the global
* settings live in the network admin in that case.
*
* @since 1.0.0
*
* @return void
*/
public function register_site_menu() {
if ( is_multisite() && 'global' === $this->settings->get_network_mode() ) {
return;
}
add_options_page(
__( 'AI Translator', 'robotstxt-ai-translator' ),
__( 'AI Translator', 'robotstxt-ai-translator' ),
'manage_options',
self::SITE_MENU_SLUG,
array( $this, 'render_site_page' )
);
}
/**
* Registers the network-level settings page.
*
* @since 1.0.0
*
* @return void
*/
public function register_network_menu() {
add_submenu_page(
'settings.php',
__( 'AI Translator', 'robotstxt-ai-translator' ),
__( 'AI Translator', 'robotstxt-ai-translator' ),
'manage_network_options',
self::NETWORK_MENU_SLUG,
array( $this, 'render_network_page' )
);
}
/**
* Adds a settings link in the site plugin list.
*
* @since 1.0.0
*
* @param array<int,string> $links Existing action links.
*
* @return array<int,string>
*/
public function add_settings_link( $links ) {
if ( is_multisite() && 'global' === $this->settings->get_network_mode() ) {
return $links;
}
$url = admin_url( 'options-general.php?page=' . self::SITE_MENU_SLUG );
$label = esc_html__( 'Settings', 'robotstxt-ai-translator' );
array_unshift( $links, '<a href="' . esc_url( $url ) . '">' . $label . '</a>' );
return $links;
}
/**
* Adds a settings link in the network plugin list.
*
* @since 1.0.0
*
* @param array<int,string> $links Existing action links.
*
* @return array<int,string>
*/
public function add_network_settings_link( $links ) {
$url = network_admin_url( 'settings.php?page=' . self::NETWORK_MENU_SLUG );
$label = esc_html__( 'Settings', 'robotstxt-ai-translator' );
array_unshift( $links, '<a href="' . esc_url( $url ) . '">' . $label . '</a>' );
return $links;
}
/**
* Renders the site-level settings page.
*
* @since 1.0.0
*
* @return void
*/
public function render_site_page() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have permission to access this page.', 'robotstxt-ai-translator' ) );
}
$current = $this->settings->get_site_settings();
$defaults = is_multisite() ? $this->settings->get_network_settings() : array();
$updated = 1 === filter_input( INPUT_GET, 'updated', FILTER_VALIDATE_INT );
?>
<div class="wrap">
<h1><?php echo esc_html__( 'AI Translator Settings', 'robotstxt-ai-translator' ); ?></h1>
<?php if ( $updated ) : ?>
<div class="notice notice-success is-dismissible">
<p><?php echo esc_html__( 'Settings saved.', 'robotstxt-ai-translator' ); ?></p>
</div>
<?php endif; ?>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<input type="hidden" name="action" value="ai_translator_save_site" />
<?php wp_nonce_field( self::NONCE_SITE ); ?>
<table class="form-table" role="presentation">
<tbody>
<tr>
<th scope="row"><?php echo esc_html__( 'Translation fields', 'robotstxt-ai-translator' ); ?></th>
<td>
<fieldset>
<legend class="screen-reader-text"><?php echo esc_html__( 'Translation fields', 'robotstxt-ai-translator' ); ?></legend>
<label for="ai-translator-translate-title">
<input type="checkbox" id="ai-translator-translate-title" name="ai_translator_settings[translate_title]" value="1" <?php checked( $current['translate_title'] ); ?> />
<?php echo esc_html__( 'Translate title', 'robotstxt-ai-translator' ); ?>
</label><br />
<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>
<?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' ),
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' )
);
?>
</p>
<?php endif; ?>
</fieldset>
</td>
</tr>
</tbody>
</table>
<?php submit_button(); ?>
</form>
<?php $this->render_model_recommendations(); ?>
</div>
<?php
}
/**
* Renders the network-level settings page.
*
* @since 1.0.0
*
* @return void
*/
public function render_network_page() {
if ( ! current_user_can( 'manage_network_options' ) ) {
wp_die( esc_html__( 'You do not have permission to access this page.', 'robotstxt-ai-translator' ) );
}
$mode = $this->settings->get_network_mode();
$network = $this->settings->get_network_settings();
$updated = 1 === filter_input( INPUT_GET, 'updated', FILTER_VALIDATE_INT );
?>
<div class="wrap">
<h1><?php echo esc_html__( 'AI Translator Network Settings', 'robotstxt-ai-translator' ); ?></h1>
<?php if ( $updated ) : ?>
<div class="notice notice-success is-dismissible">
<p><?php echo esc_html__( 'Network settings saved.', 'robotstxt-ai-translator' ); ?></p>
</div>
<?php endif; ?>
<form method="post" action="<?php echo esc_url( network_admin_url( 'edit.php?action=ai_translator_save_network' ) ); ?>">
<?php wp_nonce_field( self::NONCE_NETWORK ); ?>
<table class="form-table" role="presentation">
<tbody>
<tr>
<th scope="row"><?php echo esc_html__( 'Configuration mode', 'robotstxt-ai-translator' ); ?></th>
<td>
<fieldset>
<legend class="screen-reader-text"><?php echo esc_html__( 'Configuration mode', 'robotstxt-ai-translator' ); ?></legend>
<label>
<input type="radio" name="ai_translator_mode" value="global" <?php checked( 'global', $mode ); ?> />
<?php echo esc_html__( 'Global configuration: a single setting applies to every site.', 'robotstxt-ai-translator' ); ?>
</label><br />
<label>
<input type="radio" name="ai_translator_mode" value="per-site" <?php checked( 'per-site', $mode ); ?> />
<?php echo esc_html__( 'Per-site configuration: each site can override these defaults.', 'robotstxt-ai-translator' ); ?>
</label>
</fieldset>
</td>
</tr>
<tr>
<th scope="row">
<?php echo esc_html__( 'Translation fields', 'robotstxt-ai-translator' ); ?>
</th>
<td>
<fieldset>
<legend class="screen-reader-text"><?php echo esc_html__( 'Translation fields', 'robotstxt-ai-translator' ); ?></legend>
<label for="ai-translator-net-translate-title">
<input type="checkbox" id="ai-translator-net-translate-title" name="ai_translator_settings[translate_title]" value="1" <?php checked( $network['translate_title'] ); ?> />
<?php echo esc_html__( 'Translate title', 'robotstxt-ai-translator' ); ?>
</label><br />
<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>
<p class="description">
<?php echo esc_html__( '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.', 'robotstxt-ai-translator' ); ?>
</p>
</fieldset>
</td>
</tr>
</tbody>
</table>
<?php submit_button(); ?>
</form>
<?php $this->render_model_recommendations(); ?>
</div>
<?php
}
/**
* Handles the site-level form submission.
*
* @since 1.0.0
*
* @return void
*/
public function handle_site_save() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have permission to perform this action.', 'robotstxt-ai-translator' ) );
}
check_admin_referer( self::NONCE_SITE );
$raw = isset( $_POST['ai_translator_settings'] ) && is_array( $_POST['ai_translator_settings'] )
? wp_unslash( $_POST['ai_translator_settings'] ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitised by sanitize_settings() below.
: array();
$this->settings->update_site_settings( $this->sanitize_settings( $raw ) );
$redirect = add_query_arg(
array(
'page' => self::SITE_MENU_SLUG,
'updated' => 1,
),
admin_url( 'options-general.php' )
);
wp_safe_redirect( $redirect );
exit;
}
/**
* Handles the network-level form submission.
*
* @since 1.0.0
*
* @return void
*/
public function handle_network_save() {
if ( ! current_user_can( 'manage_network_options' ) ) {
wp_die( esc_html__( 'You do not have permission to perform this action.', 'robotstxt-ai-translator' ) );
}
check_admin_referer( self::NONCE_NETWORK );
$mode_raw = filter_input( INPUT_POST, 'ai_translator_mode', FILTER_UNSAFE_RAW );
$raw_mode = is_string( $mode_raw ) ? sanitize_key( $mode_raw ) : 'global';
$mode = in_array( $raw_mode, array( 'global', 'per-site' ), true ) ? $raw_mode : 'global';
$this->settings->update_network_mode( $mode );
$raw = isset( $_POST['ai_translator_settings'] ) && is_array( $_POST['ai_translator_settings'] )
? wp_unslash( $_POST['ai_translator_settings'] ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitised by sanitize_settings() below.
: array();
$this->settings->update_network_settings( $this->sanitize_settings( $raw ) );
$redirect = add_query_arg(
array(
'page' => self::NETWORK_MENU_SLUG,
'updated' => 1,
),
network_admin_url( 'settings.php' )
);
wp_safe_redirect( $redirect );
exit;
}
/**
* Renders the model recommendations table.
*
* Displayed below both the site and network settings pages as guidance for
* choosing a provider in the WordPress AI plugin. Purely informational; the
* table is not interactive and stores no data.
*
* @since 1.0.0
*
* @return void
*/
private function render_model_recommendations() {
$rows = $this->get_model_recommendations();
?>
<h2><?php echo esc_html__( 'Recommended models', 'robotstxt-ai-translator' ); ?></h2>
<p class="description">
<?php echo esc_html__( '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.', 'robotstxt-ai-translator' ); ?>
</p>
<table class="widefat striped ai-translator-recommendations">
<thead>
<tr>
<th scope="col"><?php echo esc_html__( 'Use case', 'robotstxt-ai-translator' ); ?></th>
<th scope="col"><?php echo esc_html__( 'Recommended model', 'robotstxt-ai-translator' ); ?></th>
<th scope="col"><?php echo esc_html__( 'Why', 'robotstxt-ai-translator' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $rows as $row ) : ?>
<tr>
<th scope="row"><?php echo esc_html( $row['use_case'] ); ?></th>
<td><?php echo esc_html( $row['model'] ); ?></td>
<td><?php echo esc_html( $row['reason'] ); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<p class="description">
<?php echo esc_html__( 'These recommendations are based on public benchmarks and community feedback as of the plugin release date. They may change as providers update their models.', 'robotstxt-ai-translator' ); ?>
</p>
<?php
}
/**
* Returns the model recommendations table data.
*
* @since 1.0.0
*
* @return array<int,array{use_case:string,model:string,reason:string}>
*/
private function get_model_recommendations() {
$rows = array(
array(
'use_case' => __( 'European languages (ES, CA, FR, DE, IT, PT)', 'robotstxt-ai-translator' ),
'model' => __( 'DeepL API Pro', 'robotstxt-ai-translator' ),
'reason' => __( 'Best fluency and naturalness (92/100 in benchmarks). Formality control and custom glossaries.', 'robotstxt-ai-translator' ),
),
array(
'use_case' => __( 'Asian languages (ZH, JA, KO)', 'robotstxt-ai-translator' ),
'model' => __( 'GPT-4o / GPT-5 or Claude Sonnet 4', 'robotstxt-ai-translator' ),
'reason' => __( 'Better handling of implicit subjects, honorifics, and cultural references. DeepL falls behind here.', 'robotstxt-ai-translator' ),
),
array(
'use_case' => __( 'Marketing / brand tone', 'robotstxt-ai-translator' ),
'model' => __( 'Claude Sonnet 4 / Opus 4', 'robotstxt-ai-translator' ),
'reason' => __( 'Better preservation of tone, brand voice, and nuance. Ideal for creative copy.', 'robotstxt-ai-translator' ),
),
array(
'use_case' => __( 'Technical documentation / code', 'robotstxt-ai-translator' ),
'model' => __( 'GPT-4o / GPT-5', 'robotstxt-ai-translator' ),
'reason' => __( 'Higher accuracy with variables, structured formats, and technical terminology.', 'robotstxt-ai-translator' ),
),
array(
'use_case' => __( 'Long documents (100+ pages)', 'robotstxt-ai-translator' ),
'model' => __( 'Gemini 2.5 Pro', 'robotstxt-ai-translator' ),
'reason' => __( '1M token context window. Maintains terminological consistency across long texts.', 'robotstxt-ai-translator' ),
),
array(
'use_case' => __( 'High volume / low cost', 'robotstxt-ai-translator' ),
'model' => __( 'DeepSeek-V3', 'robotstxt-ai-translator' ),
'reason' => __( 'Quality comparable to GPT-5 at ~$0.14/M tokens (20-50x cheaper than Claude/GPT).', 'robotstxt-ai-translator' ),
),
array(
'use_case' => __( 'Rare / indigenous languages', 'robotstxt-ai-translator' ),
'model' => __( 'Claude Sonnet or Taskade Translate (multi-model routing)', 'robotstxt-ai-translator' ),
'reason' => __( 'Better coverage for uncommon language pairs.', 'robotstxt-ai-translator' ),
),
array(
'use_case' => __( 'Maximum coverage (133+ languages)', 'robotstxt-ai-translator' ),
'model' => __( 'Google Cloud Translation', 'robotstxt-ai-translator' ),
'reason' => __( 'The most complete option, though with slightly lower quality on European languages.', 'robotstxt-ai-translator' ),
),
);
/**
* Filters the model recommendations shown in the settings pages.
*
* @since 1.0.0
*
* @param array<int,array{use_case:string,model:string,reason:string}> $rows Recommendation rows.
*/
return (array) apply_filters( 'ai_translator_model_recommendations', $rows );
}
/**
* Sanitises a raw settings array into a strict boolean schema.
*
* @since 1.0.0
*
* @param array<string,mixed> $raw Raw input from the form.
*
* @return array<string,bool>
*/
private function sanitize_settings( array $raw ) {
return array(
'translate_title' => ! empty( $raw['translate_title'] ),
'translate_content' => ! empty( $raw['translate_content'] ),
);
}
}
}

View file

@ -0,0 +1,238 @@
<?php
/**
* WP-CLI commands for the AI Translator plugin.
*
* @package ROBOTSTXT\AI_Translator
* @since 1.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'AI_Translator_CLI' ) ) {
/**
* Translate posts from the command line.
*
* ## EXAMPLES
*
* # Translate a single post to Spanish.
* $ wp ai-translator translate --post-id=42 --locale=es_ES
*
* # Translate every post of a given type, preview only.
* $ wp ai-translator translate --post-type=post --locale=fr_FR --dry-run
*
* @since 1.0.0
*/
class AI_Translator_CLI {
/**
* Settings handler.
*
* @since 1.0.0
* @var AI_Translator_Settings
*/
private $settings;
/**
* Translator handler.
*
* @since 1.0.0
* @var AI_Translator_Translator
*/
private $translator;
/**
* Constructor.
*
* @since 1.0.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 the command with WP-CLI.
*
* @since 1.0.0
*
* @return void
*/
public function register() {
if ( ! class_exists( 'WP_CLI' ) ) {
return;
}
WP_CLI::add_command( 'ai-translator', $this );
}
/**
* Translates one or more posts to a target locale.
*
* ## OPTIONS
*
* --locale=<locale>
* : WordPress locale to translate to. Must be installed on the site.
*
* [--post-id=<id>]
* : ID of a single post to translate. Mutually exclusive with --post-type.
*
* [--post-type=<type>]
* : Post type to translate in bulk. Defaults to 'post' when --post-id is omitted.
*
* [--status=<status>]
* : Post status filter when running in bulk mode. Default: 'publish'.
*
* [--limit=<n>]
* : Maximum number of posts to process when running in bulk mode. Default: 50.
*
* [--dry-run]
* : Show what would be translated without calling the AI provider and without writing to the database.
*
* ## EXAMPLES
*
* wp ai-translator translate --post-id=42 --locale=es_ES
* wp ai-translator translate --post-type=page --locale=fr_FR --limit=10 --dry-run
*
* @since 1.0.0
*
* @param array<int,string> $args Positional arguments (unused).
* @param array<string,string> $assoc_args Associative arguments.
*
* @return void
*/
public function translate( $args, $assoc_args ) {
unset( $args );
$locale = isset( $assoc_args['locale'] ) ? (string) $assoc_args['locale'] : '';
$dry_run = isset( $assoc_args['dry-run'] );
$post_id = isset( $assoc_args['post-id'] ) ? (int) $assoc_args['post-id'] : 0;
$type = isset( $assoc_args['post-type'] ) ? (string) $assoc_args['post-type'] : '';
$status = isset( $assoc_args['status'] ) ? (string) $assoc_args['status'] : 'publish';
$limit = isset( $assoc_args['limit'] ) ? max( 1, (int) $assoc_args['limit'] ) : 50;
if ( '' === $locale ) {
WP_CLI::error( __( 'The --locale flag is required.', 'robotstxt-ai-translator' ) );
}
if ( $post_id > 0 && '' !== $type ) {
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' ) );
}
if ( ! in_array( $locale, $this->translator->get_installed_locales(), true ) ) {
WP_CLI::error(
sprintf(
/* translators: %s: locale code. */
__( 'The locale %s is not installed on this site.', 'robotstxt-ai-translator' ),
$locale
)
);
}
$settings = $this->settings->get_settings();
if ( empty( $settings['translate_title'] ) && empty( $settings['translate_content'] ) ) {
WP_CLI::error( __( 'No translation fields are enabled in the plugin settings.', 'robotstxt-ai-translator' ) );
}
if ( $post_id > 0 ) {
$posts = array( get_post( $post_id ) );
} else {
$type = '' !== $type ? $type : 'post';
$posts = get_posts(
array(
'post_type' => $type,
'post_status' => $status,
'numberposts' => $limit,
'ignore_sticky_posts' => true,
'suppress_filters' => false,
)
);
}
$posts = array_filter(
$posts,
static function ( $post ) {
return $post instanceof WP_Post;
}
);
if ( empty( $posts ) ) {
WP_CLI::warning( __( 'No posts matched the given criteria.', 'robotstxt-ai-translator' ) );
return;
}
$successes = 0;
$failures = 0;
foreach ( $posts as $post ) {
if ( $dry_run ) {
WP_CLI::log( sprintf( '[dry-run] would translate post #%d (%s) to %s.', $post->ID, $post->post_title, $locale ) );
++$successes;
continue;
}
$update = array( 'ID' => $post->ID );
if ( ! empty( $settings['translate_title'] ) ) {
$translated_title = $this->translator->translate( $post->post_title, $locale );
if ( is_wp_error( $translated_title ) ) {
WP_CLI::warning( sprintf( '#%d title: %s', $post->ID, $translated_title->get_error_message() ) );
++$failures;
continue;
}
$update['post_title'] = $translated_title;
}
if ( ! empty( $settings['translate_content'] ) ) {
$translated_content = $this->translator->translate( $post->post_content, $locale );
if ( is_wp_error( $translated_content ) ) {
WP_CLI::warning( sprintf( '#%d content: %s', $post->ID, $translated_content->get_error_message() ) );
++$failures;
continue;
}
$update['post_content'] = $translated_content;
}
$result = wp_update_post( $update, true );
if ( is_wp_error( $result ) ) {
WP_CLI::warning( sprintf( '#%d save: %s', $post->ID, $result->get_error_message() ) );
++$failures;
continue;
}
WP_CLI::log( sprintf( 'Updated post #%d.', $post->ID ) );
++$successes;
}
WP_CLI::success(
sprintf(
/* translators: 1: success count, 2: failure count, 3: locale. */
__( '%1$d translated, %2$d failed (locale: %3$s).', 'robotstxt-ai-translator' ),
$successes,
$failures,
$locale
)
);
}
}
}

View file

@ -0,0 +1,549 @@
<?php
/**
* Editor UI for the AI Translator plugin.
*
* Registers the REST endpoint, the Classic editor metabox, and the Block editor
* sidebar that lets editors translate the current post.
*
* @package ROBOTSTXT\AI_Translator
* @since 1.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'AI_Translator_Editor_UI' ) ) {
/**
* Editor UI registration and REST endpoint handler.
*
* @since 1.0.0
*/
class AI_Translator_Editor_UI {
/**
* Allowed field names that can be translated through the REST endpoint.
*
* @since 1.0.0
* @var array<int,string>
*/
const ALLOWED_FIELDS = array( 'title', 'content' );
/**
* Settings handler.
*
* @since 1.0.0
* @var AI_Translator_Settings
*/
private $settings;
/**
* Translator handler.
*
* @since 1.0.0
* @var AI_Translator_Translator
*/
private $translator;
/**
* Constructor.
*
* @since 1.0.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.0.0
*
* @return void
*/
public function register() {
add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) );
add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_block_editor_assets' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_classic_editor_assets' ) );
add_action( 'add_meta_boxes', array( $this, 'register_classic_meta_box' ) );
}
/**
* Registers the REST API routes.
*
* @since 1.0.0
*
* @return void
*/
public function register_rest_routes() {
register_rest_route(
AI_TRANSLATOR_REST_NAMESPACE,
'/translate',
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'handle_translate_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',
),
'fields' => array(
'type' => 'array',
'required' => true,
'minItems' => 1,
'uniqueItems' => true,
'items' => array(
'type' => 'string',
'enum' => self::ALLOWED_FIELDS,
),
'sanitize_callback' => 'rest_sanitize_request_arg',
'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',
),
),
)
);
}
/**
* Sanitises a locale string.
*
* @since 1.0.0
*
* @param mixed $value Raw value.
*
* @return string
*/
public function sanitize_locale( $value ) {
$value = is_string( $value ) ? $value : '';
$value = preg_replace( '/[^A-Za-z0-9_\-]/', '', $value );
if ( ! is_string( $value ) ) {
return '';
}
return substr( $value, 0, 20 );
}
/**
* REST permission callback for the translate endpoint.
*
* @since 1.0.0
*
* @param WP_REST_Request $request The request object.
*
* @return bool|WP_Error True if allowed, WP_Error otherwise.
*/
public function check_translate_permissions( WP_REST_Request $request ) {
$post_id_param = $request->get_param( 'post_id' );
$post_id = is_numeric( $post_id_param ) ? (int) $post_id_param : 0;
if ( $post_id <= 0 || ! current_user_can( 'edit_post', $post_id ) ) {
return new WP_Error(
'rest_forbidden',
__( 'Sorry, you are not allowed to edit this post.', 'robotstxt-ai-translator' ),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
}
/**
* REST callback for the translate endpoint.
*
* @since 1.0.0
*
* @param WP_REST_Request $request The request object.
*
* @return WP_REST_Response|WP_Error
*/
public function handle_translate_request( WP_REST_Request $request ) {
$post_id_param = $request->get_param( 'post_id' );
$target_locale_param = $request->get_param( 'target_locale' );
$post_id = is_numeric( $post_id_param ) ? (int) $post_id_param : 0;
$fields = (array) $request->get_param( 'fields' );
$target_locale = is_string( $target_locale_param ) ? $target_locale_param : '';
$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 )
);
}
$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 )
);
}
$site_settings = $this->settings->get_settings();
$allowed = array();
if ( in_array( 'title', $fields, true ) && ! empty( $site_settings['translate_title'] ) ) {
$allowed[] = 'title';
}
if ( in_array( 'content', $fields, true ) && ! empty( $site_settings['translate_content'] ) ) {
$allowed[] = 'content';
}
if ( empty( $allowed ) ) {
return new WP_Error(
'ai_translator_no_fields',
__( 'No translatable fields were selected, or the requested fields are disabled in the settings.', 'robotstxt-ai-translator' ),
array( 'status' => 400 )
);
}
$response = array();
if ( in_array( 'title', $allowed, true ) ) {
$translated = $this->translator->translate( $post->post_title, $target_locale );
if ( is_wp_error( $translated ) ) {
return $this->error_with_status( $translated, 502 );
}
$response['title'] = $translated;
}
if ( in_array( 'content', $allowed, true ) ) {
$translated = $this->translator->translate( $post->post_content, $target_locale );
if ( is_wp_error( $translated ) ) {
return $this->error_with_status( $translated, 502 );
}
$response['content'] = $translated;
}
return new WP_REST_Response( $response, 200 );
}
/**
* Attaches an HTTP status to a WP_Error without overwriting existing data.
*
* @since 1.0.0
*
* @param WP_Error $error The original error.
* @param int $status HTTP status code to add.
*
* @return WP_Error
*/
private function error_with_status( WP_Error $error, $status ) {
$existing = (array) $error->get_error_data();
$error->add_data( array_merge( $existing, array( 'status' => (int) $status ) ) );
return $error;
}
/**
* Registers the Classic editor metabox.
*
* @since 1.0.0
*
* @return void
*/
public function register_classic_meta_box() {
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
// The block editor already shows the AI Translator via the PluginSidebar
// (translation icon in the top-right). Skipping metabox registration there
// avoids duplicating the UI in the post settings panel.
if ( $screen && method_exists( $screen, 'is_block_editor' ) && $screen->is_block_editor() ) {
return;
}
$post_types = $this->get_supported_post_types();
foreach ( $post_types as $post_type ) {
add_meta_box(
'ai-translator',
__( 'AI Translator', 'robotstxt-ai-translator' ),
array( $this, 'render_classic_meta_box' ),
$post_type,
'side',
'default'
);
}
}
/**
* Renders the Classic editor metabox content.
*
* @since 1.0.0
*
* @param WP_Post $post The current post.
*
* @return void
*/
public function render_classic_meta_box( $post ) {
$languages = $this->get_language_list();
$settings = $this->settings->get_settings();
?>
<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__( '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>
<?php else : ?>
<p>
<label for="ai-translator-locale"><?php echo esc_html__( 'Target language', 'robotstxt-ai-translator' ); ?></label>
<select id="ai-translator-locale" class="widefat">
<?php foreach ( $languages as $language ) : ?>
<option value="<?php echo esc_attr( $language['locale'] ); ?>"><?php echo esc_html( $language['name'] ); ?></option>
<?php endforeach; ?>
</select>
</p>
<p>
<button type="button" class="button button-primary" id="ai-translator-classic-button">
<?php echo esc_html__( 'Translate', 'robotstxt-ai-translator' ); ?>
</button>
</p>
<p class="description">
<?php echo esc_html__( 'Save the post before translating to ensure the latest content is used. The translation replaces the current title and/or content.', 'robotstxt-ai-translator' ); ?>
</p>
<div id="ai-translator-classic-status" class="ai-translator-status" role="status" aria-live="polite"></div>
<?php endif; ?>
</div>
<?php
}
/**
* Enqueues assets for the Classic editor.
*
* @since 1.0.0
*
* @param string $hook Current admin page hook.
*
* @return void
*/
public function enqueue_classic_editor_assets( $hook ) {
if ( ! in_array( $hook, array( 'post.php', 'post-new.php' ), true ) ) {
return;
}
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
if ( $screen && method_exists( $screen, 'is_block_editor' ) && $screen->is_block_editor() ) {
return;
}
$this->enqueue_assets( 'classic' );
}
/**
* Enqueues assets for the Block editor.
*
* @since 1.0.0
*
* @return void
*/
public function enqueue_block_editor_assets() {
$this->enqueue_assets( 'block' );
}
/**
* Common asset enqueue logic for both editors.
*
* @since 1.0.0
*
* @param string $context Either 'classic' or 'block'.
*
* @return void
*/
private function enqueue_assets( $context ) {
$dependencies = array( 'wp-api-fetch', 'wp-i18n' );
if ( 'block' === $context ) {
$dependencies = array_merge(
$dependencies,
array(
'wp-plugins',
'wp-editor',
'wp-element',
'wp-components',
'wp-data',
'wp-blocks',
)
);
}
$asset_ver = AI_TRANSLATOR_VERSION . '.' . (string) filemtime( AI_TRANSLATOR_DIR . 'assets/js/editor.js' );
wp_register_script(
'ai-translator-editor',
AI_TRANSLATOR_URL . 'assets/js/editor.js',
$dependencies,
$asset_ver,
true
);
wp_register_style(
'ai-translator-editor',
AI_TRANSLATOR_URL . 'assets/css/editor.css',
array(),
$asset_ver
);
$post_id = $this->resolve_current_post_id();
$settings = $this->settings->get_settings();
$data = array(
'restNamespace' => AI_TRANSLATOR_REST_NAMESPACE,
'restRoute' => '/translate',
'context' => $context,
'postId' => $post_id,
'available' => $this->translator->is_available(),
'languages' => $this->get_language_list(),
'settings' => array(
'translateTitle' => ! empty( $settings['translate_title'] ),
'translateContent' => ! empty( $settings['translate_content'] ),
),
'i18n' => array(
'translate' => __( 'Translate', 'robotstxt-ai-translator' ),
'translating' => __( 'Translating…', 'robotstxt-ai-translator' ),
'targetLanguage' => __( 'Target language', 'robotstxt-ai-translator' ),
'panelTitle' => __( 'AI Translator', 'robotstxt-ai-translator' ),
'success' => __( 'Translation applied. Review and save the post.', 'robotstxt-ai-translator' ),
'genericError' => __( 'Translation failed.', 'robotstxt-ai-translator' ),
'noLanguages' => __( 'No languages are installed on this site.', 'robotstxt-ai-translator' ),
'noFields' => __( 'No translation fields are enabled in settings.', 'robotstxt-ai-translator' ),
'aiUnavailable' => __( 'The WordPress AI plugin is not active.', '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' ),
),
);
wp_add_inline_script(
'ai-translator-editor',
'window.aiTranslatorData = ' . wp_json_encode( $data ) . ';',
'before'
);
wp_enqueue_script( 'ai-translator-editor' );
wp_enqueue_style( 'ai-translator-editor' );
}
/**
* Resolves the current post ID when enqueueing assets.
*
* @since 1.0.0
*
* @return int Post ID, or 0 when it cannot be determined.
*/
private function resolve_current_post_id() {
$post = get_post();
if ( $post instanceof WP_Post ) {
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;
}
return 0;
}
/**
* Returns the post types where the metabox should appear.
*
* @since 1.0.0
*
* @return array<int,string>
*/
private function get_supported_post_types() {
$post_types = get_post_types( array( 'public' => true ), 'names' );
if ( ! is_array( $post_types ) ) {
return array();
}
unset( $post_types['attachment'] );
/**
* Filters the post types where the AI Translator metabox is registered.
*
* @since 1.0.0
*
* @param array<int,string> $post_types List of post type names.
*/
return (array) apply_filters( 'ai_translator_supported_post_types', array_values( $post_types ) );
}
/**
* Returns the list of languages available for translation on this site.
*
* Includes en_US plus any locales whose .mo files are installed.
*
* @since 1.0.0
*
* @return array<int,array{locale:string,name:string}>
*/
private function get_language_list() {
$locales = $this->translator->get_installed_locales();
if ( ! function_exists( 'wp_get_available_translations' ) ) {
require_once ABSPATH . 'wp-admin/includes/translation-install.php';
}
$translations = function_exists( 'wp_get_available_translations' ) ? wp_get_available_translations() : array();
$languages = array();
foreach ( $locales as $locale ) {
if ( 'en_US' === $locale ) {
$name = 'English (United States)';
} elseif ( isset( $translations[ $locale ]['native_name'] ) && '' !== $translations[ $locale ]['native_name'] ) {
$name = (string) $translations[ $locale ]['native_name'];
} elseif ( isset( $translations[ $locale ]['english_name'] ) && '' !== $translations[ $locale ]['english_name'] ) {
$name = (string) $translations[ $locale ]['english_name'];
} else {
$name = $locale;
}
$languages[] = array(
'locale' => $locale,
'name' => $name,
);
}
usort(
$languages,
static function ( $a, $b ) {
return strcasecmp( $a['name'], $b['name'] );
}
);
return $languages;
}
}
}

View file

@ -0,0 +1,210 @@
<?php
/**
* Settings management for the AI Translator plugin.
*
* @package ROBOTSTXT\AI_Translator
* @since 1.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'AI_Translator_Settings' ) ) {
/**
* Handles reading and writing the plugin settings in single-site and Multisite contexts.
*
* In Multisite, the network admin chooses between two modes:
* - "global" : a single configuration shared by every subsite.
* - "per-site" : each subsite stores its own configuration, with the network values
* acting as defaults when a subsite has not been configured yet.
*
* @since 1.0.0
*/
class AI_Translator_Settings {
/**
* Option key for site-level settings (single-site or per-site Multisite mode).
*
* @since 1.0.0
* @var string
*/
const OPTION_SITE = 'ai_translator_settings';
/**
* Sitemeta key for network-level settings (Multisite global mode and defaults).
*
* @since 1.0.0
* @var string
*/
const OPTION_NETWORK = 'ai_translator_settings';
/**
* Sitemeta key for the configuration mode in Multisite.
*
* @since 1.0.0
* @var string
*/
const OPTION_MODE = 'ai_translator_network_mode';
/**
* Default settings values.
*
* @since 1.0.0
* @var array<string,bool>
*/
const DEFAULTS = array(
'translate_title' => true,
'translate_content' => true,
);
/**
* Returns the current configuration mode in Multisite.
*
* @since 1.0.0
*
* @return string Either 'global' or 'per-site'. Returns 'global' on single-site installs.
*/
public function get_network_mode() {
if ( ! is_multisite() ) {
return 'global';
}
$mode = get_site_option( self::OPTION_MODE, 'global' );
return in_array( $mode, array( 'global', 'per-site' ), true ) ? $mode : 'global';
}
/**
* Updates the Multisite configuration mode.
*
* @since 1.0.0
*
* @param string $mode Either 'global' or 'per-site'.
*
* @return bool True on success.
*/
public function update_network_mode( $mode ) {
if ( ! is_multisite() ) {
return false;
}
$mode = in_array( $mode, array( 'global', 'per-site' ), true ) ? $mode : 'global';
return (bool) update_site_option( self::OPTION_MODE, $mode );
}
/**
* Returns the effective settings for a given site, applying multisite mode rules.
*
* @since 1.0.0
*
* @param int|null $blog_id Optional blog ID. Defaults to the current site.
*
* @return array<string,bool> Settings array with boolean values.
*/
public function get_settings( $blog_id = null ) {
if ( ! is_multisite() ) {
return $this->normalize( (array) get_option( self::OPTION_SITE, array() ) );
}
$mode = $this->get_network_mode();
if ( 'global' === $mode ) {
return $this->normalize( (array) get_site_option( self::OPTION_NETWORK, array() ) );
}
$network_defaults = $this->normalize( (array) get_site_option( self::OPTION_NETWORK, array() ) );
if ( null === $blog_id || get_current_blog_id() === (int) $blog_id ) {
$site_settings = get_option( self::OPTION_SITE, null );
} else {
switch_to_blog( (int) $blog_id );
$site_settings = get_option( self::OPTION_SITE, null );
restore_current_blog();
}
if ( null === $site_settings ) {
return $network_defaults;
}
return $this->normalize( array_merge( $network_defaults, (array) $site_settings ) );
}
/**
* Returns the raw network-level settings (used by the network admin form).
*
* @since 1.0.0
*
* @return array<string,bool>
*/
public function get_network_settings() {
return $this->normalize( (array) get_site_option( self::OPTION_NETWORK, array() ) );
}
/**
* Returns the raw site-level settings (used by the site admin form).
*
* @since 1.0.0
*
* @return array<string,bool>
*/
public function get_site_settings() {
return $this->normalize( (array) get_option( self::OPTION_SITE, array() ) );
}
/**
* Persists the network-level settings (Multisite only).
*
* @since 1.0.0
*
* @param array<string,bool> $settings Settings to store.
*
* @return bool True on success.
*/
public function update_network_settings( array $settings ) {
if ( ! is_multisite() ) {
return false;
}
return (bool) update_site_option( self::OPTION_NETWORK, $this->normalize( $settings ) );
}
/**
* Persists the site-level settings.
*
* @since 1.0.0
*
* @param array<string,bool> $settings Settings to store.
*
* @return bool True on success.
*/
public function update_site_settings( array $settings ) {
return (bool) update_option( self::OPTION_SITE, $this->normalize( $settings ) );
}
/**
* Normalises raw settings into a strict array of booleans matching the schema.
*
* @since 1.0.0
*
* @param array<int|string,mixed> $settings Raw settings.
*
* @return array<string,bool>
*/
private function normalize( array $settings ) {
$normalized = array();
foreach ( self::DEFAULTS as $key => $default ) {
if ( array_key_exists( $key, $settings ) ) {
$normalized[ $key ] = (bool) $settings[ $key ];
} else {
$normalized[ $key ] = (bool) $default;
}
}
return $normalized;
}
}
}

View file

@ -0,0 +1,201 @@
<?php
/**
* Translation engine for the AI Translator plugin.
*
* Wraps the WordPress AI client (https://github.com/WordPress/ai) so the rest of the
* plugin does not need to know which AI provider is configured.
*
* @package ROBOTSTXT\AI_Translator
* @since 1.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'AI_Translator_Translator' ) ) {
/**
* Talks to the WordPress AI client to translate strings.
*
* @since 1.0.0
*/
class AI_Translator_Translator {
/**
* Indicates whether the WordPress AI client is available.
*
* @since 1.0.0
*
* @return bool True if the dependency is active.
*/
public function is_available() {
return function_exists( 'wp_ai_client_prompt' );
}
/**
* Indicates whether the WordPress AI client supports text generation right now.
*
* The check uses a tiny dummy prompt so the AI client can answer whether at
* least one configured provider can serve text generation requests.
*
* @since 1.0.0
*
* @return bool True if text generation is supported.
*/
public function is_supported() {
if ( ! $this->is_available() ) {
return false;
}
try {
$builder = wp_ai_client_prompt( 'test' );
return (bool) $builder->is_supported_for_text_generation();
} catch ( Exception $e ) {
return false;
} catch ( Throwable $e ) {
return false;
}
}
/**
* Translates a single piece of text to the target locale.
*
* @since 1.0.0
*
* @param string $text Source text. Plain text or HTML; preserved as-is.
* @param string $target_locale WordPress locale code (for example: es_ES, en_US, fr_FR).
*
* @return string|WP_Error Translated text, or WP_Error on failure.
*/
public function translate( $text, $target_locale ) {
$text = (string) $text;
if ( '' === trim( $text ) ) {
return '';
}
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' )
);
}
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' )
);
}
$language_name = $this->locale_to_language_name( $target_locale );
if ( '' === $language_name ) {
return new WP_Error(
'ai_translator_invalid_locale',
__( 'The target language is not installed on this site.', 'robotstxt-ai-translator' )
);
}
$system_instruction = sprintf(
/* translators: %s: target language name. */
__( '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.', 'robotstxt-ai-translator' ),
$language_name
);
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() );
} catch ( Throwable $e ) {
return new WP_Error( 'ai_translator_exception', $e->getMessage() );
}
if ( is_wp_error( $result ) ) {
return $result;
}
if ( ! is_string( $result ) ) {
return new WP_Error(
'ai_translator_invalid_response',
__( 'The AI service returned an unexpected response.', 'robotstxt-ai-translator' )
);
}
return trim( $result );
}
/**
* Converts a WordPress locale code into a human-readable language name.
*
* Only locales installed on the site (en_US plus any installed .po files) are
* accepted, so the editor cannot request translations to languages that the
* site has not declared as available.
*
* @since 1.0.0
*
* @param string $locale WordPress locale code.
*
* @return string Native language name, or empty string when the locale is not installed.
*/
public function locale_to_language_name( $locale ) {
$locale = (string) $locale;
if ( '' === $locale ) {
return '';
}
$installed = $this->get_installed_locales();
if ( ! in_array( $locale, $installed, true ) ) {
return '';
}
if ( 'en_US' === $locale ) {
return 'English (United States)';
}
if ( ! function_exists( 'wp_get_available_translations' ) ) {
require_once ABSPATH . 'wp-admin/includes/translation-install.php';
}
if ( function_exists( 'wp_get_available_translations' ) ) {
$translations = wp_get_available_translations();
if ( isset( $translations[ $locale ]['native_name'] ) && '' !== $translations[ $locale ]['native_name'] ) {
return (string) $translations[ $locale ]['native_name'];
}
if ( isset( $translations[ $locale ]['english_name'] ) && '' !== $translations[ $locale ]['english_name'] ) {
return (string) $translations[ $locale ]['english_name'];
}
}
return $locale;
}
/**
* Returns the locales installed on the site, including en_US.
*
* @since 1.0.0
*
* @return array<int,string> List of locale codes.
*/
public function get_installed_locales() {
$locales = array( 'en_US' );
if ( function_exists( 'get_available_languages' ) ) {
$installed = get_available_languages();
if ( is_array( $installed ) ) {
foreach ( $installed as $locale ) {
$locales[] = (string) $locale;
}
}
}
return array_values( array_unique( $locales ) );
}
}
}