execute( array( 'language_code' => 'fr_FR' ) ); * if ( is_wp_error( $result ) ) { ... } * * Calling them over REST (requires authentication): * * POST /wp-json/wp-abilities/v1/abilities/translangated/add-language/run * Content-Type: application/json * { "input": { "language_code": "fr_FR" } } * * See the Abilities API docs for schemas, annotations, and execution details: * * @see https://developer.wordpress.org/apis/abilities-api/ */ class Translangated_Abilities { const CATEGORY = 'translangated'; public function __construct() { if ( ! $this->is_abilities_api_available() ) { return; } add_action( 'wp_abilities_api_categories_init', array( $this, 'register_category' ) ); add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); } private function is_abilities_api_available(): bool { return class_exists( 'WP_Ability' ) && function_exists( 'wp_register_ability' ); } public function register_category(): void { wp_register_ability_category( self::CATEGORY, array( 'label' => __( 'Translangated', 'translangated' ), 'description' => __( 'Abilities for managing Translangated configuration.', 'translangated' ), ) ); } public function register_abilities(): void { wp_register_ability( 'translangated/add-language', array( 'label' => __( 'Add a translation language', 'translangated' ), 'description' => __( 'Adds a translation language to Translangated. Enforces the same language limit as the settings UI.', 'translangated' ), 'category' => self::CATEGORY, 'input_schema' => array( 'type' => 'object', 'properties' => array( 'language_code' => array( 'type' => 'string', 'description' => __( 'WordPress locale code (e.g. fr_FR, de_DE).', 'translangated' ), 'minLength' => 1, ), 'slug' => array( 'type' => 'string', 'description' => __( 'Optional URL slug. Defaults to the ISO language code if omitted.', 'translangated' ), ), ), 'required' => array( 'language_code' ), 'additionalProperties' => false, ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'added' => array( 'type' => 'string' ), 'translation_languages' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ), ), ), ), 'execute_callback' => array( $this, 'execute_add_language' ), 'permission_callback' => array( $this, 'permission_manage_options' ), 'meta' => array( 'show_in_rest' => true ), ) ); wp_register_ability( 'translangated/enable-automatic-translation', array( 'label' => __( 'Enable Automatic Translation', 'translangated' ), 'description' => __( 'Turns on Automatic Translation using Google Translate or DeepL (configure the engine API key in the settings). If no engine is passed and none is configured, defaults to google_translate_v2.', 'translangated' ), 'category' => self::CATEGORY, 'input_schema' => array( 'type' => 'object', 'properties' => array( 'engine' => array( 'type' => 'string', 'description' => __( 'Optional translation-engine slug to select ("google_translate_v2" or "deepl"). When omitted, keeps the already-configured engine, or defaults to "google_translate_v2" if none is configured.', 'translangated' ), 'minLength' => 1, ), ), 'additionalProperties' => false, // Empty-object input; the same `default: stdClass` trick used by the // GET abilities lets this be called with no `input` payload at all. 'default' => new stdClass(), ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'machine_translation' => array( 'type' => 'string' ), 'translation_engine' => array( 'type' => 'string' ), ), ), 'execute_callback' => array( $this, 'execute_enable_automatic_translation' ), 'permission_callback' => array( $this, 'permission_manage_options' ), 'meta' => array( 'show_in_rest' => true ), ) ); wp_register_ability( 'translangated/list-languages', array( 'label' => __( 'List configured languages', 'translangated' ), 'description' => __( 'Returns the languages currently configured in Translangated, including the default language and per-language slug/publish state.', 'translangated' ), 'category' => self::CATEGORY, 'input_schema' => array( 'type' => 'object', 'properties' => array(), 'additionalProperties' => false, // GET requests cannot deliver an object via query string (the Abilities // API does not JSON-decode `?input=...`). Providing a default lets callers // omit the parameter entirely and have normalize_input() fill it in. 'default' => new stdClass(), ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'default_language' => array( 'type' => 'string' ), 'translation_languages' => array( 'type' => 'array', 'items' => array( 'type' => 'object', 'properties' => array( 'code' => array( 'type' => 'string' ), 'name' => array( 'type' => 'string' ), 'slug' => array( 'type' => 'string' ), 'published' => array( 'type' => 'boolean' ), 'is_default'=> array( 'type' => 'boolean' ), ), ), ), ), ), 'execute_callback' => array( $this, 'execute_list_languages' ), 'permission_callback' => array( $this, 'permission_manage_options' ), 'meta' => array( 'show_in_rest' => true, 'annotations' => array( 'readonly' => true ), ), ) ); wp_register_ability( 'translangated/list-available-languages', array( 'label' => __( 'List available language codes', 'translangated' ), 'description' => __( 'Returns the full catalog of language codes Translangated recognizes, with their English names.', 'translangated' ), 'category' => self::CATEGORY, 'input_schema' => array( 'type' => 'object', 'properties' => array(), 'additionalProperties' => false, 'default' => new stdClass(), ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'languages' => array( 'type' => 'array', 'items' => array( 'type' => 'object', 'properties' => array( 'code' => array( 'type' => 'string' ), 'name' => array( 'type' => 'string' ), ), ), ), ), ), 'execute_callback' => array( $this, 'execute_list_available_languages' ), 'permission_callback' => array( $this, 'permission_manage_options' ), 'meta' => array( 'show_in_rest' => true, 'annotations' => array( 'readonly' => true ), ), ) ); wp_register_ability( 'translangated/remove-language', array( 'label' => __( 'Remove a translation language', 'translangated' ), 'description' => __( 'Removes a configured translation language. The default language cannot be removed without first reassigning the default.', 'translangated' ), 'category' => self::CATEGORY, 'input_schema' => array( 'type' => 'object', 'properties' => array( 'language_code' => array( 'type' => 'string', 'description' => __( 'Locale code of the language to remove.', 'translangated' ), 'minLength' => 1, ), ), 'required' => array( 'language_code' ), 'additionalProperties' => false, ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'removed' => array( 'type' => 'string' ), 'translation_languages' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ), ), ), ), 'execute_callback' => array( $this, 'execute_remove_language' ), 'permission_callback' => array( $this, 'permission_manage_options' ), 'meta' => array( 'show_in_rest' => true, // Marked destructive for semantic clarity. We deliberately omit // 'idempotent' because the Abilities API routes destructive+idempotent // to DELETE, whose query-string `input` is not JSON-decoded — so // object-shaped input would fail schema validation over the wire. 'annotations' => array( 'destructive' => true ), ), ) ); wp_register_ability( 'translangated/update-language', array( 'label' => __( 'Update a translation language', 'translangated' ), 'description' => __( 'Replaces the slug and publish status of an already-configured translation language.', 'translangated' ), 'category' => self::CATEGORY, 'input_schema' => array( 'type' => 'object', 'properties' => array( 'language_code' => array( 'type' => 'string', 'description' => __( 'Locale code of the language to update.', 'translangated' ), 'minLength' => 1, ), 'slug' => array( 'type' => 'string', 'description' => __( 'URL slug for this language.', 'translangated' ), 'minLength' => 1, ), 'published' => array( 'type' => 'boolean', 'description' => __( 'Whether this language is published on the front end.', 'translangated' ), ), ), 'required' => array( 'language_code', 'slug', 'published' ), 'additionalProperties' => false, ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'updated' => array( 'type' => 'string' ), 'slug' => array( 'type' => 'string' ), 'published' => array( 'type' => 'boolean' ), ), ), 'execute_callback' => array( $this, 'execute_update_language' ), 'permission_callback' => array( $this, 'permission_manage_options' ), 'meta' => array( 'show_in_rest' => true ), ) ); wp_register_ability( 'translangated/set-default-language', array( 'label' => __( 'Set the default language', 'translangated' ), 'description' => __( 'Promotes an already-configured language to be the site default. The previous default remains in the translation languages list.', 'translangated' ), 'category' => self::CATEGORY, 'input_schema' => array( 'type' => 'object', 'properties' => array( 'language_code' => array( 'type' => 'string', 'description' => __( 'Locale code to promote to default. Must already be a configured translation language.', 'translangated' ), 'minLength' => 1, ), ), 'required' => array( 'language_code' ), 'additionalProperties' => false, ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'default_language' => array( 'type' => 'string' ), 'previous_default_language'=> array( 'type' => 'string' ), ), ), 'execute_callback' => array( $this, 'execute_set_default_language' ), 'permission_callback' => array( $this, 'permission_manage_options' ), 'meta' => array( 'show_in_rest' => true ), ) ); } public function permission_manage_options() { if ( ! current_user_can( 'manage_options' ) ) { return new WP_Error( 'translangated_abilities_forbidden', __( 'You do not have permission to manage Translangated settings.', 'translangated' ), array( 'status' => 403 ) ); } return true; } /** * Adds a single translation language by reusing Translangated_Settings::sanitize_settings(). * * Mirrors Translangated_Step_Languages::save_languages() — same option key, same * sanitization path — so register_setting() hooks, table creation, etc. * all fire identically to a UI save. */ public function execute_add_language( $input ) { $language_code = isset( $input['language_code'] ) ? sanitize_text_field( $input['language_code'] ) : ''; if ( ! translangated_is_valid_language_code( $language_code ) ) { return new WP_Error( 'translangated_invalid_language_code', __( 'Invalid language code. Allowed characters: A-Z, a-z, 0-9, hyphen, underscore.', 'translangated' ) ); } $trp = Translangated_Plugin::get_translangated_instance(); $languages = $trp->get_component( 'languages' )->get_languages(); $settings_c = $trp->get_component( 'settings' ); if ( ! array_key_exists( $language_code, $languages ) ) { return new WP_Error( 'translangated_unknown_language', sprintf( /* translators: %s: language code */ __( 'Language code "%s" is not recognized by Translangated.', 'translangated' ), $language_code ) ); } $translangated_settings = get_option( 'translangated_settings', array() ); $default_language = isset( $translangated_settings['default-language'] ) ? $translangated_settings['default-language'] : 'en_US'; $translation_languages = isset( $translangated_settings['translation-languages'] ) ? (array) $translangated_settings['translation-languages'] : array(); $publish_languages = isset( $translangated_settings['publish-languages'] ) ? (array) $translangated_settings['publish-languages'] : array(); $url_slugs = isset( $translangated_settings['url-slugs'] ) ? (array) $translangated_settings['url-slugs'] : array(); if ( $language_code === $default_language || in_array( $language_code, $translation_languages, true ) ) { return new WP_Error( 'translangated_language_already_added', sprintf( /* translators: %s: language code */ __( 'Language "%s" is already configured.', 'translangated' ), $language_code ) ); } // Same limit gate the UI uses — the bundled extra-languages add-on raises it via the filter. $max_secondary = (int) apply_filters( 'translangated_secondary_languages', 1 ); $secondary_count = count( array_diff( $translation_languages, array( $default_language ) ) ); if ( $secondary_count >= $max_secondary ) { return new WP_Error( 'translangated_language_limit_reached', sprintf( /* translators: %d: maximum number of secondary languages */ __( 'Translangated allows up to %d additional language(s).', 'translangated' ), $max_secondary ), array( 'max_secondary_languages' => $max_secondary ) ); } $translation_languages[] = $language_code; $publish_languages[] = $language_code; $slug = isset( $input['slug'] ) ? sanitize_title( $input['slug'] ) : ''; if ( '' === $slug ) { $slug = sanitize_title( strtok( $language_code, '_' ) ); } $url_slugs[ $language_code ] = $slug; $translangated_settings['default-language'] = $default_language; $translangated_settings['translation-languages'] = array_values( array_unique( $translation_languages ) ); $translangated_settings['publish-languages'] = array_values( array_unique( $publish_languages ) ); $translangated_settings['url-slugs'] = $url_slugs; $sanitized = $settings_c->sanitize_settings( $translangated_settings ); // sanitize_settings() can short-circuit with a get_option fallback when invalid codes slip through. if ( ! is_array( $sanitized ) || ! in_array( $language_code, (array) $sanitized['translation-languages'], true ) ) { return new WP_Error( 'translangated_settings_rejected', __( 'Translangated rejected the new language during settings sanitization.', 'translangated' ) ); } update_option( 'translangated_settings', $sanitized ); return array( 'added' => $language_code, 'translation_languages' => array_values( (array) $sanitized['translation-languages'] ), ); } /** * Turns on Automatic Translation. * * Engine resolution, in order: * 1. `$input['engine']` when provided, wins. * 2. Existing `translangated_machine_translation_settings['translation-engine']`, if it is still an available engine. * 3. Falls back to 'google_translate_v2'. */ public function execute_enable_automatic_translation( $input = array() ) { // Normalize: REST delivers an assoc array; direct PHP callers may pass // an stdClass (that's what the `default: new stdClass()` schema produces // when no `input` payload is supplied). $input = is_array( $input ) ? $input : (array) $input; $settings = get_option( 'translangated_machine_translation_settings', array() ); if ( ! is_array( $settings ) ) { $settings = array(); } $requested_engine = isset( $input['engine'] ) ? sanitize_text_field( (string) $input['engine'] ) : ''; $existing_engine = isset( $settings['translation-engine'] ) ? (string) $settings['translation-engine'] : ''; $available_engines = wp_list_pluck( apply_filters( 'translangated_machine_translation_engines', array() ), 'value' ); if ( '' !== $requested_engine ) { if ( ! in_array( $requested_engine, $available_engines, true ) ) { return new WP_Error( 'translangated_invalid_engine', /* translators: %s: comma-separated list of engine slugs */ sprintf( __( 'Unknown translation engine. Available engines: %s.', 'translangated' ), implode( ', ', $available_engines ) ) ); } $engine = $requested_engine; } elseif ( in_array( $existing_engine, $available_engines, true ) ) { $engine = $existing_engine; } else { $engine = 'google_translate_v2'; } $settings['machine-translation'] = 'yes'; $settings['translation-engine'] = $engine; update_option( 'translangated_machine_translation_settings', $settings ); return array( 'machine_translation' => 'yes', 'translation_engine' => $engine, ); } public function execute_list_languages() { $translangated_settings = get_option( 'translangated_settings', array() ); $trp = Translangated_Plugin::get_translangated_instance(); $names = $trp->get_component( 'languages' )->get_languages(); $default = isset( $translangated_settings['default-language'] ) ? $translangated_settings['default-language'] : ''; $translation = isset( $translangated_settings['translation-languages'] ) ? (array) $translangated_settings['translation-languages'] : array(); $publish = isset( $translangated_settings['publish-languages'] ) ? (array) $translangated_settings['publish-languages'] : array(); $url_slugs = isset( $translangated_settings['url-slugs'] ) ? (array) $translangated_settings['url-slugs'] : array(); $items = array(); foreach ( $translation as $code ) { $items[] = array( 'code' => $code, 'name' => isset( $names[ $code ] ) ? $names[ $code ] : $code, 'slug' => isset( $url_slugs[ $code ] ) ? $url_slugs[ $code ] : '', 'published' => in_array( $code, $publish, true ), 'is_default' => ( $code === $default ), ); } return array( 'default_language' => $default, 'translation_languages' => $items, ); } public function execute_list_available_languages() { $trp = Translangated_Plugin::get_translangated_instance(); $names = $trp->get_component( 'languages' )->get_languages(); $items = array(); foreach ( $names as $code => $name ) { $items[] = array( 'code' => $code, 'name' => $name ); } return array( 'languages' => $items ); } public function execute_remove_language( $input ) { $code = sanitize_text_field( $input['language_code'] ); if ( ! translangated_is_valid_language_code( $code ) ) { return new WP_Error( 'translangated_invalid_language_code', __( 'Invalid language code. Allowed characters: A-Z, a-z, 0-9, hyphen, underscore.', 'translangated' ) ); } $translangated_settings = get_option( 'translangated_settings', array() ); $default_language = isset( $translangated_settings['default-language'] ) ? $translangated_settings['default-language'] : 'en_US'; $translation_languages = isset( $translangated_settings['translation-languages'] ) ? (array) $translangated_settings['translation-languages'] : array(); $publish_languages = isset( $translangated_settings['publish-languages'] ) ? (array) $translangated_settings['publish-languages'] : array(); $url_slugs = isset( $translangated_settings['url-slugs'] ) ? (array) $translangated_settings['url-slugs'] : array(); if ( ! in_array( $code, $translation_languages, true ) ) { return new WP_Error( 'translangated_language_not_configured', sprintf( /* translators: %s: language code */ __( 'Language "%s" is not currently configured.', 'translangated' ), $code ) ); } if ( $code === $default_language ) { return new WP_Error( 'translangated_cannot_remove_default', __( 'The default language cannot be removed. Set another language as default first.', 'translangated' ) ); } $translangated_settings['translation-languages'] = array_values( array_diff( $translation_languages, array( $code ) ) ); $translangated_settings['publish-languages'] = array_values( array_diff( $publish_languages, array( $code ) ) ); unset( $url_slugs[ $code ] ); $translangated_settings['url-slugs'] = $url_slugs; $sanitized = $this->save_translangated_settings( $translangated_settings ); if ( is_wp_error( $sanitized ) ) { return $sanitized; } return array( 'removed' => $code, 'translation_languages' => array_values( (array) $sanitized['translation-languages'] ), ); } public function execute_update_language( $input ) { $code = sanitize_text_field( $input['language_code'] ); $slug = sanitize_title( $input['slug'] ); $published = (bool) $input['published']; if ( ! translangated_is_valid_language_code( $code ) ) { return new WP_Error( 'translangated_invalid_language_code', __( 'Invalid language code. Allowed characters: A-Z, a-z, 0-9, hyphen, underscore.', 'translangated' ) ); } if ( '' === $slug ) { return new WP_Error( 'translangated_invalid_slug', __( 'Slug is empty after sanitization. Use only URL-safe characters.', 'translangated' ) ); } $translangated_settings = get_option( 'translangated_settings', array() ); $default_language = isset( $translangated_settings['default-language'] ) ? $translangated_settings['default-language'] : 'en_US'; $translation_languages = isset( $translangated_settings['translation-languages'] ) ? (array) $translangated_settings['translation-languages'] : array(); $publish_languages = isset( $translangated_settings['publish-languages'] ) ? (array) $translangated_settings['publish-languages'] : array(); $url_slugs = isset( $translangated_settings['url-slugs'] ) ? (array) $translangated_settings['url-slugs'] : array(); if ( ! in_array( $code, $translation_languages, true ) ) { return new WP_Error( 'translangated_language_not_configured', sprintf( /* translators: %s: language code */ __( 'Language "%s" is not currently configured. Add it first with translangated/add-language.', 'translangated' ), $code ) ); } // The default language is always published; refuse to unpublish it. if ( $code === $default_language && ! $published ) { return new WP_Error( 'translangated_cannot_unpublish_default', __( 'The default language is always published and cannot be unpublished.', 'translangated' ) ); } // Reject slug collision with another configured language. foreach ( $url_slugs as $other_code => $other_slug ) { if ( $other_code !== $code && $other_slug === $slug ) { return new WP_Error( 'translangated_duplicate_slug', sprintf( /* translators: 1: slug, 2: language code */ __( 'Slug "%1$s" is already in use by language "%2$s".', 'translangated' ), $slug, $other_code ) ); } } $url_slugs[ $code ] = $slug; $translangated_settings['url-slugs'] = $url_slugs; $publish_set = array_flip( $publish_languages ); if ( $published ) { $publish_set[ $code ] = true; } else { unset( $publish_set[ $code ] ); } $translangated_settings['publish-languages'] = array_keys( $publish_set ); $sanitized = $this->save_translangated_settings( $translangated_settings ); if ( is_wp_error( $sanitized ) ) { return $sanitized; } return array( 'updated' => $code, 'slug' => isset( $sanitized['url-slugs'][ $code ] ) ? $sanitized['url-slugs'][ $code ] : $slug, 'published' => in_array( $code, (array) $sanitized['publish-languages'], true ), ); } public function execute_set_default_language( $input ) { $code = sanitize_text_field( $input['language_code'] ); if ( ! translangated_is_valid_language_code( $code ) ) { return new WP_Error( 'translangated_invalid_language_code', __( 'Invalid language code. Allowed characters: A-Z, a-z, 0-9, hyphen, underscore.', 'translangated' ) ); } $translangated_settings = get_option( 'translangated_settings', array() ); $previous_default = isset( $translangated_settings['default-language'] ) ? $translangated_settings['default-language'] : 'en_US'; $translation_languages = isset( $translangated_settings['translation-languages'] ) ? (array) $translangated_settings['translation-languages'] : array(); if ( $code === $previous_default ) { return array( 'default_language' => $code, 'previous_default_language' => $previous_default, ); } if ( ! in_array( $code, $translation_languages, true ) ) { return new WP_Error( 'translangated_language_not_configured', sprintf( /* translators: %s: language code */ __( 'Language "%s" is not currently configured. Add it first with translangated/add-language.', 'translangated' ), $code ) ); } $translangated_settings['default-language'] = $code; // sanitize_settings() will reinsert the new default at the head and keep the old default in the list. $sanitized = $this->save_translangated_settings( $translangated_settings ); if ( is_wp_error( $sanitized ) ) { return $sanitized; } return array( 'default_language' => (string) $sanitized['default-language'], 'previous_default_language' => $previous_default, ); } /** * Runs translangated_settings through Translangated_Settings::sanitize_settings() and persists it. * Returns the sanitized array on success or WP_Error if sanitization rejected the payload. */ private function save_translangated_settings( array $translangated_settings ) { $settings_c = Translangated_Plugin::get_translangated_instance()->get_component( 'settings' ); $sanitized = $settings_c->sanitize_settings( $translangated_settings ); if ( ! is_array( $sanitized ) || ! isset( $sanitized['translation-languages'] ) ) { return new WP_Error( 'translangated_settings_rejected', __( 'Translangated rejected the new settings during sanitization.', 'translangated' ) ); } update_option( 'translangated_settings', $sanitized ); return $sanitized; } }