robotstxt-ai-translator/includes/class-ai-translator-admin.php
2026-05-23 10:13:56 +00:00

530 lines
18 KiB
PHP

<?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'] ),
);
}
}
}