diff --git a/slytranslate/assets/settings-page.js b/slytranslate/assets/settings-page.js new file mode 100644 index 0000000..849b54c --- /dev/null +++ b/slytranslate/assets/settings-page.js @@ -0,0 +1,614 @@ +/** + * SlyTranslate settings page (Settings → SlyTranslate). + * + * Thin client over GET/POST ai-translate/v1/settings: all state lives in the + * backend payload, the page renders it and posts back only changed fields so + * server-side side effects (direct-API probe on URL change) fire exactly when + * intended. Built on @wordpress/components for native admin look & feel. + */ +( function ( wp, config ) { + 'use strict'; + + if ( ! wp || ! config ) { + return; + } + + var el = wp.element.createElement; + var Fragment = wp.element.Fragment; + var useState = wp.element.useState; + var useEffect = wp.element.useEffect; + var __ = wp.i18n.__; + var sprintf = wp.i18n.sprintf; + var apiFetch = wp.apiFetch; + var c = wp.components; + + // Fields the form edits; everything else in the payload is diagnostics. + var EDITABLE_FIELDS = [ + 'model_slug', + 'prompt_addon', + 'meta_keys_translate', + 'meta_keys_clear', + 'prompt_template', + 'context_window_tokens', + 'direct_api_url', + 'string_table_concurrency', + ]; + + function pickEditable( payload ) { + var form = {}; + EDITABLE_FIELDS.forEach( function ( key ) { + form[ key ] = payload[ key ]; + } ); + return form; + } + + function changedFields( baseline, form ) { + var changed = {}; + EDITABLE_FIELDS.forEach( function ( key ) { + if ( String( baseline[ key ] ) !== String( form[ key ] ) ) { + changed[ key ] = form[ key ]; + } + } ); + return changed; + } + + function formatTimestamp( unix ) { + if ( ! unix ) { + return ''; + } + return new Date( unix * 1000 ).toLocaleString(); + } + + function StatusBadge( props ) { + var ok = props.ok; + var symbol = ok ? '✓' : '✗'; + return el( + 'span', + { style: { marginRight: '24px', color: ok ? '#00753f' : '#b32d2e' } }, + symbol + ' ' + props.label + ); + } + + function StatusSection( props ) { + var s = props.settings; + var modelLabel = s.model_slug || __( 'Connector default', 'slytranslate' ); + return el( + c.PanelBody, + { title: __( 'Status', 'slytranslate' ), initialOpen: true }, + el( + 'p', + null, + el( StatusBadge, { + ok: s.language_plugin_detected, + label: s.language_plugin_detected + ? sprintf( __( '%s detected', 'slytranslate' ), s.language_plugin_label ) + : __( 'No language plugin detected', 'slytranslate' ), + } ), + el( StatusBadge, { + ok: !! s.detected_seo_plugin, + label: s.detected_seo_plugin + ? sprintf( __( '%s detected', 'slytranslate' ), s.detected_seo_plugin_label ) + : __( 'No SEO plugin detected', 'slytranslate' ), + } ), + el( StatusBadge, { + ok: s.ai_client_available, + label: s.ai_client_available + ? __( 'AI client available', 'slytranslate' ) + : __( 'AI client unavailable', 'slytranslate' ), + } ), + el( + 'span', + { style: { marginRight: '24px' } }, + __( 'Model:', 'slytranslate' ) + ' ' + modelLabel + ) + ), + s.direct_api_url + ? el( + 'p', + null, + el( StatusBadge, { + ok: s.direct_api_kwargs_supported, + label: s.direct_api_kwargs_supported + ? __( 'Direct API supports chat_template_kwargs', 'slytranslate' ) + : __( 'Direct API: chat_template_kwargs not detected', 'slytranslate' ), + } ), + s.direct_api_kwargs_last_probed_at + ? el( + 'span', + { style: { color: '#757575' } }, + sprintf( + __( 'Last probed: %s', 'slytranslate' ), + formatTimestamp( s.direct_api_kwargs_last_probed_at ) + ) + ) + : null + ) + : null + ); + } + + function ModelSection( props ) { + var options = [ + { value: '', label: __( 'Connector default', 'slytranslate' ) }, + ].concat( + props.models.map( function ( m ) { + return { value: m.value, label: m.label }; + } ) + ); + + // Keep a saved slug visible even when its connector is offline. + var known = options.some( function ( o ) { + return o.value === props.value; + } ); + if ( ! known && props.value ) { + options.push( { value: props.value, label: props.value } ); + } + + return el( + c.PanelBody, + { title: __( 'Model', 'slytranslate' ), initialOpen: true }, + el( c.SelectControl, { + label: __( 'Default model', 'slytranslate' ), + help: __( 'Site-wide default model for all translations. Individual translations can override it in the editor.', 'slytranslate' ), + value: props.value, + options: options, + onChange: props.onChange, + __nextHasNoMarginBottom: true, + } ), + el( + c.Button, + { + variant: 'secondary', + isBusy: props.refreshing, + disabled: props.refreshing, + onClick: props.onRefresh, + style: { marginTop: '8px' }, + }, + __( 'Refresh model list', 'slytranslate' ) + ) + ); + } + + function TranslationSection( props ) { + return el( + c.PanelBody, + { title: __( 'Translation', 'slytranslate' ), initialOpen: true }, + el( c.TextareaControl, { + label: __( 'Additional instructions (site-wide)', 'slytranslate' ), + help: __( 'Appended to every translation request. Example: Use informal language.', 'slytranslate' ), + value: props.promptAddon, + onChange: props.onChangePromptAddon, + rows: 3, + __nextHasNoMarginBottom: true, + } ) + ); + } + + function MetaSection( props ) { + var s = props.settings; + var effective = ( s.effective_meta_keys_translate || [] ).join( ', ' ); + var effectiveClear = ( s.effective_meta_keys_clear || [] ).join( ', ' ); + return el( + c.PanelBody, + { title: __( 'Meta fields', 'slytranslate' ), initialOpen: true }, + s.detected_seo_plugin + ? el( + 'p', + { style: { color: '#757575' } }, + sprintf( + __( 'Detected automatically (%s): translated: %s — cleared: %s', 'slytranslate' ), + s.detected_seo_plugin_label, + effective || '—', + effectiveClear || '—' + ) + ) + : null, + el( c.TextareaControl, { + label: __( 'Additional keys to translate', 'slytranslate' ), + help: __( 'Whitespace-separated meta keys translated in addition to the automatically detected ones.', 'slytranslate' ), + value: props.form.meta_keys_translate, + onChange: function ( v ) { + props.onChange( 'meta_keys_translate', v ); + }, + rows: 2, + __nextHasNoMarginBottom: true, + } ), + el( c.TextareaControl, { + label: __( 'Additional keys to clear', 'slytranslate' ), + help: __( 'Whitespace-separated meta keys emptied on the translated post.', 'slytranslate' ), + value: props.form.meta_keys_clear, + onChange: function ( v ) { + props.onChange( 'meta_keys_clear', v ); + }, + rows: 2, + __nextHasNoMarginBottom: true, + } ) + ); + } + + function ProbeResultTable( props ) { + var levels = props.result.levels || []; + if ( ! props.result.supported ) { + return el( + c.Notice, + { status: 'warning', isDismissible: false }, + __( 'No parallel HTTP transport is available on this server.', 'slytranslate' ) + ); + } + return el( + Fragment, + null, + el( + 'p', + null, + sprintf( + __( 'Recommended concurrency: %d', 'slytranslate' ), + props.result.recommended + ) + ), + levels.length + ? el( + 'table', + { className: 'widefat striped', style: { maxWidth: '480px' } }, + el( + 'thead', + null, + el( + 'tr', + null, + el( 'th', null, __( 'Level', 'slytranslate' ) ), + el( 'th', null, __( 'Wall time (ms)', 'slytranslate' ) ), + el( 'th', null, __( 'Speedup', 'slytranslate' ) ), + el( 'th', null, __( 'Errors', 'slytranslate' ) ) + ) + ), + el( + 'tbody', + null, + levels.map( function ( level ) { + return el( + 'tr', + { key: level.level }, + el( 'td', null, String( level.level ) ), + el( 'td', null, String( level.wall_ms ) ), + el( 'td', null, String( level.speedup ) ), + el( 'td', null, String( level.errors ) ) + ); + } ) + ) + ) + : null + ); + } + + function AdvancedSection( props ) { + var s = props.settings; + var form = props.form; + var learned = s.learned_context_windows || {}; + var learnedKeys = Object.keys( learned ); + var diagnostics = s.last_transport_diagnostics || {}; + var diagnosticsKeys = Object.keys( diagnostics ); + + return el( + c.PanelBody, + { title: __( 'Advanced', 'slytranslate' ), initialOpen: false }, + el( c.TextareaControl, { + label: __( 'Prompt template', 'slytranslate' ), + help: __( 'Use {FROM_CODE} and {TO_CODE} as placeholders for the source and target language.', 'slytranslate' ), + value: form.prompt_template, + onChange: function ( v ) { + props.onChange( 'prompt_template', v ); + }, + rows: 4, + __nextHasNoMarginBottom: true, + } ), + el( + c.Button, + { + variant: 'tertiary', + disabled: form.prompt_template === s.default_prompt_template, + onClick: function () { + props.onChange( 'prompt_template', s.default_prompt_template ); + }, + }, + __( 'Reset to default', 'slytranslate' ) + ), + el( c.TextControl, { + label: __( 'Context window override (tokens)', 'slytranslate' ), + help: + __( '0 = automatic detection.', 'slytranslate' ) + + ( learnedKeys.length + ? ' ' + + sprintf( + __( 'Learned values: %s', 'slytranslate' ), + learnedKeys + .map( function ( key ) { + return key + ': ' + learned[ key ]; + } ) + .join( ', ' ) + ) + : '' ), + type: 'number', + min: 0, + value: String( form.context_window_tokens ), + onChange: function ( v ) { + props.onChange( 'context_window_tokens', parseInt( v, 10 ) || 0 ); + }, + style: { maxWidth: '200px', marginTop: '16px' }, + __nextHasNoMarginBottom: true, + } ), + el( c.TextControl, { + label: __( 'Direct API URL', 'slytranslate' ), + help: __( 'Base URL of an optional OpenAI-compatible server. Used only by models that require direct API handling (e.g. TranslateGemma). Saving probes the endpoint.', 'slytranslate' ), + value: form.direct_api_url, + onChange: function ( v ) { + props.onChange( 'direct_api_url', v ); + }, + style: { marginTop: '16px' }, + __nextHasNoMarginBottom: true, + } ), + s.is_string_table_adapter + ? el( + Fragment, + null, + el( c.RangeControl, { + label: __( 'String-table concurrency', 'slytranslate' ), + help: __( 'Maximum parallel batches for string-table translations. Values above 1 only activate after a successful probe.', 'slytranslate' ), + min: 1, + max: 4, + value: form.string_table_concurrency, + onChange: function ( v ) { + props.onChange( 'string_table_concurrency', v ); + }, + style: { maxWidth: '320px', marginTop: '16px' }, + __nextHasNoMarginBottom: true, + } ), + el( + c.Button, + { + variant: 'secondary', + isBusy: props.probing, + disabled: props.probing, + onClick: props.onProbe, + }, + __( 'Test concurrency', 'slytranslate' ) + ), + props.probeResult + ? el( ProbeResultTable, { result: props.probeResult } ) + : null + ) + : null, + diagnosticsKeys.length + ? el( + Fragment, + null, + el( 'h4', null, __( 'Transport diagnostics', 'slytranslate' ) ), + el( + 'ul', + { style: { color: '#757575' } }, + diagnosticsKeys.map( function ( key ) { + return el( + 'li', + { key: key }, + key + ': ' + String( diagnostics[ key ] ) + ); + } ) + ) + ) + : null + ); + } + + function SettingsApp() { + var settingsState = useState( null ); + var settings = settingsState[ 0 ]; + var setSettings = settingsState[ 1 ]; + + var formState = useState( null ); + var form = formState[ 0 ]; + var setForm = formState[ 1 ]; + + var modelsState = useState( [] ); + var models = modelsState[ 0 ]; + var setModels = modelsState[ 1 ]; + + var errorState = useState( '' ); + var error = errorState[ 0 ]; + var setError = errorState[ 1 ]; + + var savingState = useState( false ); + var saving = savingState[ 0 ]; + var setSaving = savingState[ 1 ]; + + var refreshingState = useState( false ); + var refreshing = refreshingState[ 0 ]; + var setRefreshing = refreshingState[ 1 ]; + + var probingState = useState( false ); + var probing = probingState[ 0 ]; + var setProbing = probingState[ 1 ]; + + var probeResultState = useState( null ); + var probeResult = probeResultState[ 0 ]; + var setProbeResult = probeResultState[ 1 ]; + + var snackbarState = useState( '' ); + var snackbar = snackbarState[ 0 ]; + var setSnackbar = snackbarState[ 1 ]; + + function applyPayload( payload ) { + setSettings( payload ); + setForm( pickEditable( payload ) ); + } + + function loadModels( refresh ) { + return apiFetch( { + path: config.modelsPath, + method: 'POST', + data: { input: { refresh: !! refresh } }, + } ).then( function ( response ) { + setModels( ( response && response.models ) || [] ); + } ); + } + + useEffect( function () { + Promise.all( [ + apiFetch( { path: config.settingsPath } ), + loadModels( false ), + ] ) + .then( function ( results ) { + applyPayload( results[ 0 ] ); + } ) + .catch( function ( err ) { + setError( ( err && err.message ) || __( 'Loading settings failed.', 'slytranslate' ) ); + } ); + }, [] ); + + if ( error && ! settings ) { + return el( c.Notice, { status: 'error', isDismissible: false }, error ); + } + + if ( ! settings || ! form ) { + return el( c.Spinner, null ); + } + + var dirty = changedFields( pickEditable( settings ), form ); + var hasChanges = Object.keys( dirty ).length > 0; + + function onChangeField( key, value ) { + var next = {}; + Object.keys( form ).forEach( function ( existing ) { + next[ existing ] = form[ existing ]; + } ); + next[ key ] = value; + setForm( next ); + } + + function onSave() { + setSaving( true ); + setError( '' ); + apiFetch( { + path: config.settingsPath, + method: 'POST', + data: dirty, + } ) + .then( function ( payload ) { + applyPayload( payload ); + setSnackbar( __( 'Settings saved.', 'slytranslate' ) ); + } ) + .catch( function ( err ) { + setError( ( err && err.message ) || __( 'Saving failed.', 'slytranslate' ) ); + } ) + .finally( function () { + setSaving( false ); + } ); + } + + function onRefreshModels() { + setRefreshing( true ); + loadModels( true ).finally( function () { + setRefreshing( false ); + } ); + } + + function onProbe() { + setProbing( true ); + setProbeResult( null ); + apiFetch( { + path: config.probeConcurrencyPath, + method: 'POST', + data: { model_slug: form.model_slug }, + } ) + .then( setProbeResult ) + .catch( function ( err ) { + setError( ( err && err.message ) || __( 'Concurrency probe failed.', 'slytranslate' ) ); + } ) + .finally( function () { + setProbing( false ); + } ); + } + + return el( + Fragment, + null, + el( 'h1', null, 'SlyTranslate' ), + error ? el( c.Notice, { status: 'error', onRemove: function () { setError( '' ); } }, error ) : null, + el( + c.Panel, + null, + el( StatusSection, { settings: settings } ), + el( ModelSection, { + models: models, + value: form.model_slug, + refreshing: refreshing, + onChange: function ( v ) { + onChangeField( 'model_slug', v ); + }, + onRefresh: onRefreshModels, + } ), + el( TranslationSection, { + promptAddon: form.prompt_addon, + onChangePromptAddon: function ( v ) { + onChangeField( 'prompt_addon', v ); + }, + } ), + el( MetaSection, { + settings: settings, + form: form, + onChange: onChangeField, + } ), + el( AdvancedSection, { + settings: settings, + form: form, + onChange: onChangeField, + probing: probing, + probeResult: probeResult, + onProbe: onProbe, + } ) + ), + el( + 'p', + { style: { marginTop: '16px' } }, + el( + c.Button, + { + variant: 'primary', + isBusy: saving, + disabled: saving || ! hasChanges, + onClick: onSave, + }, + __( 'Save settings', 'slytranslate' ) + ) + ), + snackbar + ? el( + c.Snackbar, + { + onRemove: function () { + setSnackbar( '' ); + }, + }, + snackbar + ) + : null + ); + } + + function mount() { + var root = document.getElementById( 'slytranslate-settings-root' ); + if ( ! root ) { + return; + } + if ( wp.element.createRoot ) { + wp.element.createRoot( root ).render( el( SettingsApp ) ); + } else { + wp.element.render( el( SettingsApp ), root ); + } + } + + if ( 'loading' === document.readyState ) { + document.addEventListener( 'DOMContentLoaded', mount ); + } else { + mount(); + } +} )( window.wp, window.slyTranslateSettings ); diff --git a/slytranslate/inc/SettingsPage.php b/slytranslate/inc/SettingsPage.php new file mode 100644 index 0000000..6d0f8cd --- /dev/null +++ b/slytranslate/inc/SettingsPage.php @@ -0,0 +1,209 @@ +
'; + } + + /** + * Enqueue the settings app only on its own admin page. + * + * @param string $hook_suffix Current admin page hook suffix. + */ + public static function enqueue_assets( $hook_suffix = '' ): void { + if ( 'settings_page_' . self::MENU_SLUG !== $hook_suffix ) { + return; + } + + wp_enqueue_style( 'wp-components' ); + + wp_enqueue_script( + self::SCRIPT_HANDLE, + plugins_url( 'assets/settings-page.js', dirname( __DIR__ ) . '/slytranslate.php' ), + array( 'wp-api-fetch', 'wp-components', 'wp-element', 'wp-i18n' ), + self::get_script_version(), + true + ); + + wp_localize_script( + self::SCRIPT_HANDLE, + 'slyTranslateSettings', + array( + 'settingsPath' => '/' . Plugin::REST_NAMESPACE . '/settings', + 'probeConcurrencyPath' => '/' . Plugin::REST_NAMESPACE . '/settings/probe-concurrency', + 'modelsPath' => '/' . Plugin::REST_NAMESPACE . '/ai-translate/get-available-models/run', + ) + ); + + if ( function_exists( 'wp_set_script_translations' ) ) { + wp_set_script_translations( + self::SCRIPT_HANDLE, + 'slytranslate', + plugin_dir_path( dirname( __DIR__ ) . '/slytranslate.php' ) . 'languages' + ); + } + } + + /* --------------------------------------------------------------- + * REST backend + * ------------------------------------------------------------- */ + + public static function register_rest_routes(): void { + $admin_permission = static function (): bool { + return current_user_can( 'manage_options' ); + }; + + register_rest_route( + Plugin::REST_NAMESPACE, + '/settings', + array( + array( + 'methods' => 'GET', + 'callback' => static function () { + return self::get_settings_payload(); + }, + 'permission_callback' => $admin_permission, + ), + array( + 'methods' => 'POST', + 'callback' => static function ( $request ) { + return self::get_settings_payload( self::extract_input( $request ) ); + }, + 'permission_callback' => $admin_permission, + ), + ) + ); + + register_rest_route( + Plugin::REST_NAMESPACE, + '/settings/probe-concurrency', + array( + 'methods' => 'POST', + 'callback' => static function ( $request ) { + $input = self::extract_input( $request ); + $model_slug = isset( $input['model_slug'] ) && is_string( $input['model_slug'] ) ? $input['model_slug'] : ''; + if ( '' === trim( $model_slug ) ) { + $model_slug = TranslationRuntime::get_requested_model_slug(); + } + + return ConfigurationService::probe_string_table_concurrency( $model_slug ); + }, + 'permission_callback' => $admin_permission, + ) + ); + } + + /** + * Save (when $input is non-empty) and return the full settings payload. + * + * Reuses AI_Translate::execute_configure() — an empty input is a pure + * read — and enriches the result with the environment facts the status + * line needs (active language plugin, AI client availability) which the + * configure payload does not carry. + * + * @param array $input Settings to persist; empty array reads only. + * @return array|\WP_Error + */ + public static function get_settings_payload( array $input = array() ) { + $result = AI_Translate::execute_configure( $input ); + if ( is_wp_error( $result ) ) { + return $result; + } + + $adapter = AI_Translate::get_adapter(); + + $result['language_plugin_detected'] = null !== $adapter; + $result['language_plugin_label'] = null !== $adapter ? self::adapter_label( $adapter ) : ''; + $result['is_string_table_adapter'] = AI_Translate::is_single_entry_translation_mode(); + $result['ai_client_available'] = AI_Translate::is_server_translation_ui_available(); + $result['default_prompt_template'] = AI_Translate::get_default_prompt(); + + $learned = get_option( 'slytranslate_learned_context_windows', array() ); + $result['learned_context_windows'] = is_array( $learned ) ? $learned : array(); + + return $result; + } + + private static function adapter_label( TranslationPluginAdapter $adapter ): string { + if ( $adapter instanceof PolylangAdapter ) { + return 'Polylang'; + } + if ( $adapter instanceof TranslatePressAdapter ) { + return 'TranslatePress'; + } + if ( $adapter instanceof WpglobusAdapter ) { + return 'WPGlobus'; + } + if ( $adapter instanceof WpMultilangAdapter ) { + return 'WP Multilang'; + } + + $class = get_class( $adapter ); + $short = substr( $class, (int) strrpos( $class, '\\' ) ); + return trim( str_replace( array( '\\', 'Adapter' ), '', $short ) ); + } + + /** + * Extract the request payload, accepting both a flat JSON body and the + * `{ "input": { … } }` envelope used by the ability REST bridge. + * + * @param mixed $request REST request object. + */ + private static function extract_input( $request ): array { + if ( ! is_object( $request ) || ! method_exists( $request, 'get_json_params' ) ) { + return array(); + } + + $payload = $request->get_json_params(); + if ( ! is_array( $payload ) ) { + return array(); + } + + if ( isset( $payload['input'] ) && is_array( $payload['input'] ) ) { + return $payload['input']; + } + + return $payload; + } + + private static function get_script_version(): string { + $script_path = dirname( __DIR__ ) . '/assets/settings-page.js'; + $script_mtime = file_exists( $script_path ) ? filemtime( $script_path ) : false; + + if ( false === $script_mtime ) { + return Plugin::VERSION; + } + + return Plugin::VERSION . '.' . (string) $script_mtime; + } +} diff --git a/slytranslate/slytranslate.php b/slytranslate/slytranslate.php index 545ce28..b92ed33 100644 --- a/slytranslate/slytranslate.php +++ b/slytranslate/slytranslate.php @@ -112,7 +112,10 @@ public static function add_hooks(): void { self::maybe_migrate_legacy_options(); add_action( 'enqueue_block_editor_assets', array( EditorBootstrap::class, 'enqueue_editor_plugin' ) ); add_action( 'admin_init', array( Settings::class, 'register' ) ); + add_action( 'admin_menu', array( SettingsPage::class, 'register_menu' ) ); + add_action( 'admin_enqueue_scripts', array( SettingsPage::class, 'enqueue_assets' ) ); add_action( 'rest_api_init', array( self::class, 'register_editor_rest_routes' ) ); + add_action( 'rest_api_init', array( SettingsPage::class, 'register_rest_routes' ) ); add_action( 'wp_abilities_api_categories_init', array( AbilityRegistrar::class, 'register_ability_category' ) ); add_action( 'wp_abilities_api_init', array( AbilityRegistrar::class, 'register_abilities' ) ); TranslatePressEditorIntegration::add_hooks(); diff --git a/slytranslate/tests/Unit/HookRegistrationTest.php b/slytranslate/tests/Unit/HookRegistrationTest.php index f964ccc..05aa643 100644 --- a/slytranslate/tests/Unit/HookRegistrationTest.php +++ b/slytranslate/tests/Unit/HookRegistrationTest.php @@ -9,6 +9,7 @@ use SlyTranslate\EditorBootstrap; use SlyTranslate\ListTableTranslation; use SlyTranslate\Settings; +use SlyTranslate\SettingsPage; use SlyTranslate\TranslatePressEditorIntegration; class HookRegistrationTest extends TestCase { @@ -60,6 +61,20 @@ static function ( string $hook, $callback, int $priority = 10, int $accepted_arg 'priority' => 10, 'accepted_args' => 1, ), + array( + 'type' => 'action', + 'hook' => 'admin_menu', + 'callback' => array( SettingsPage::class, 'register_menu' ), + 'priority' => 10, + 'accepted_args' => 1, + ), + array( + 'type' => 'action', + 'hook' => 'admin_enqueue_scripts', + 'callback' => array( SettingsPage::class, 'enqueue_assets' ), + 'priority' => 10, + 'accepted_args' => 1, + ), array( 'type' => 'action', 'hook' => 'rest_api_init', @@ -67,6 +82,13 @@ static function ( string $hook, $callback, int $priority = 10, int $accepted_arg 'priority' => 10, 'accepted_args' => 1, ), + array( + 'type' => 'action', + 'hook' => 'rest_api_init', + 'callback' => array( SettingsPage::class, 'register_rest_routes' ), + 'priority' => 10, + 'accepted_args' => 1, + ), array( 'type' => 'action', 'hook' => 'wp_abilities_api_categories_init', diff --git a/slytranslate/tests/Unit/SettingsPageTest.php b/slytranslate/tests/Unit/SettingsPageTest.php new file mode 100644 index 0000000..81d585b --- /dev/null +++ b/slytranslate/tests/Unit/SettingsPageTest.php @@ -0,0 +1,219 @@ +setStaticProperty( AI_Translate::class, 'adapter', null ); + } + + protected function tearDown(): void { + $this->setStaticProperty( AI_Translate::class, 'adapter', null ); + $this->setStaticProperty( TranslationRuntime::class, 'context', null ); + parent::tearDown(); + } + + /* --- menu ------------------------------------------------------ */ + + public function test_register_menu_registers_options_page_for_admins(): void { + $captured = null; + + $this->stubWpFunction( + 'add_options_page', + static function ( ...$args ) use ( &$captured ): string { + $captured = $args; + return 'settings_page_slytranslate'; + } + ); + + SettingsPage::register_menu(); + + $this->assertIsArray( $captured ); + $this->assertSame( 'manage_options', $captured[2] ); + $this->assertSame( SettingsPage::MENU_SLUG, $captured[3] ); + $this->assertIsCallable( $captured[4] ); + } + + /* --- assets ---------------------------------------------------- */ + + public function test_enqueue_assets_skips_other_admin_pages(): void { + $enqueued = array(); + + $this->stubWpFunction( + 'wp_enqueue_script', + static function ( ...$args ) use ( &$enqueued ): void { + $enqueued[] = $args[0]; + } + ); + + SettingsPage::enqueue_assets( 'index.php' ); + SettingsPage::enqueue_assets( 'settings_page_other-plugin' ); + + $this->assertSame( array(), $enqueued ); + } + + public function test_enqueue_assets_enqueues_settings_app_on_own_page(): void { + $scripts = array(); + $localized = null; + + $this->stubWpFunction( + 'wp_enqueue_script', + static function ( ...$args ) use ( &$scripts ): void { + $scripts[ $args[0] ] = $args; + } + ); + $this->stubWpFunction( + 'wp_localize_script', + static function ( string $handle, string $object_name, array $data ) use ( &$localized ): void { + $localized = array( $handle, $object_name, $data ); + } + ); + + SettingsPage::enqueue_assets( 'settings_page_' . SettingsPage::MENU_SLUG ); + + $this->assertArrayHasKey( SettingsPage::SCRIPT_HANDLE, $scripts ); + + $dependencies = $scripts[ SettingsPage::SCRIPT_HANDLE ][2]; + foreach ( array( 'wp-api-fetch', 'wp-components', 'wp-element', 'wp-i18n' ) as $dependency ) { + $this->assertContains( $dependency, $dependencies ); + } + + $this->assertSame( SettingsPage::SCRIPT_HANDLE, $localized[0] ); + $this->assertSame( 'slyTranslateSettings', $localized[1] ); + $this->assertSame( '/' . Plugin::REST_NAMESPACE . '/settings', $localized[2]['settingsPath'] ); + $this->assertSame( '/' . Plugin::REST_NAMESPACE . '/settings/probe-concurrency', $localized[2]['probeConcurrencyPath'] ); + } + + /* --- REST routes ------------------------------------------------ */ + + public function test_register_rest_routes_registers_settings_routes_with_admin_permission(): void { + $registered = array(); + + $this->stubWpFunction( + 'register_rest_route', + static function ( string $namespace, string $route, array $args ) use ( &$registered ): void { + $registered[ $route ] = array( + 'namespace' => $namespace, + 'args' => $args, + ); + } + ); + + SettingsPage::register_rest_routes(); + + $this->assertSame( + array( '/settings', '/settings/probe-concurrency' ), + array_keys( $registered ) + ); + + foreach ( $registered as $route ) { + $this->assertSame( Plugin::REST_NAMESPACE, $route['namespace'] ); + } + + $settings_methods = array_column( $registered['/settings']['args'], 'methods' ); + $this->assertSame( array( 'GET', 'POST' ), $settings_methods ); + $this->assertSame( 'POST', $registered['/settings/probe-concurrency']['args']['methods'] ); + + // Permission gate: manage_options decides, for every route config. + $permission_callbacks = array_merge( + array_column( $registered['/settings']['args'], 'permission_callback' ), + array( $registered['/settings/probe-concurrency']['args']['permission_callback'] ) + ); + + $this->stubWpFunctionReturn( 'current_user_can', false ); + foreach ( $permission_callbacks as $permission_callback ) { + $this->assertFalse( $permission_callback() ); + } + + $this->stubWpFunction( + 'current_user_can', + static function ( string $capability ): bool { + return 'manage_options' === $capability; + } + ); + foreach ( $permission_callbacks as $permission_callback ) { + $this->assertTrue( $permission_callback() ); + } + } + + /* --- payload ----------------------------------------------------- */ + + public function test_get_settings_payload_enriches_configure_output_with_environment_facts(): void { + $this->stubWpFunction( + 'get_option', + static function ( $option, $default = false ) { + if ( 'slytranslate_learned_context_windows' === $option ) { + return array( 'lfm2.5-8b' => 32768 ); + } + + return $default; + } + ); + + $result = SettingsPage::get_settings_payload(); + + $this->assertIsArray( $result ); + + // Configure payload comes through untouched … + $this->assertArrayHasKey( 'prompt_template', $result ); + $this->assertArrayHasKey( 'model_slug', $result ); + $this->assertArrayHasKey( 'last_transport_diagnostics', $result ); + + // … plus the status-line enrichment. + $this->assertFalse( $result['language_plugin_detected'] ); + $this->assertSame( '', $result['language_plugin_label'] ); + $this->assertFalse( $result['is_string_table_adapter'] ); + $this->assertTrue( $result['ai_client_available'] ); + $this->assertSame( AI_Translate::get_default_prompt(), $result['default_prompt_template'] ); + $this->assertSame( array( 'lfm2.5-8b' => 32768 ), $result['learned_context_windows'] ); + } + + public function test_settings_post_route_accepts_flat_and_enveloped_payloads(): void { + $registered = array(); + + $this->stubWpFunction( + 'register_rest_route', + static function ( string $namespace, string $route, array $args ) use ( &$registered ): void { + $registered[ $route ] = $args; + } + ); + + $saved_options = array(); + $this->stubWpFunction( + 'update_option', + static function ( string $option, $value ) use ( &$saved_options ): bool { + $saved_options[ $option ] = $value; + return true; + } + ); + + SettingsPage::register_rest_routes(); + $post_callback = $registered['/settings'][1]['callback']; + + $flat_request = new class() { + public function get_json_params(): array { + return array( 'prompt_addon' => 'Use informal language.' ); + } + }; + $result = $post_callback( $flat_request ); + $this->assertIsArray( $result ); + $this->assertSame( 'Use informal language.', $saved_options['slytranslate_prompt_addon'] ); + + $enveloped_request = new class() { + public function get_json_params(): array { + return array( 'input' => array( 'prompt_addon' => 'Formal tone.' ) ); + } + }; + $post_callback( $enveloped_request ); + $this->assertSame( 'Formal tone.', $saved_options['slytranslate_prompt_addon'] ); + } +} diff --git a/slytranslate/tests/stubs/wp-stubs.php b/slytranslate/tests/stubs/wp-stubs.php index 116ec7f..28d4830 100644 --- a/slytranslate/tests/stubs/wp-stubs.php +++ b/slytranslate/tests/stubs/wp-stubs.php @@ -70,6 +70,11 @@ function register_rest_route( ...$args ): void { return null; } ); } +function add_options_page( ...$args ) { + return slytranslate_test_call_override( __FUNCTION__, $args, static function () { + return ''; + } ); +} function register_activation_hook( ...$args ): void { slytranslate_test_call_override( __FUNCTION__, $args, static function () { return null;