, link: array{0?: string, 1?: string}}
*/
public function guide(): array {
$steps = array(
array( __( 'Open the Google Cloud console and sign in.', 'swiftling' ), 'https://console.cloud.google.com/' ),
array( __( 'Create a project for this site.', 'swiftling' ), 'https://console.cloud.google.com/projectcreate' ),
array( __( 'Turn on billing for the project. Google asks for a payment card even for the characters it translates free of charge each month.', 'swiftling' ), 'https://console.cloud.google.com/billing' ),
array(
/* translators: %s: the label of Google's button, Enable, in bold. */
sprintf( esc_html__( 'Enable the Cloud Translation API. On its page, only click %s.', 'swiftling' ), '' . esc_html__( 'Enable', 'swiftling' ) . '' ),
'https://console.cloud.google.com/apis/library/translate.googleapis.com',
__( 'There is nothing to choose there: the price list on that page is for information only. SwiftLing uses the standard text translation, never the LLM, Adaptive or Custom models.', 'swiftling' ),
),
array( __( 'Create an API key: Credentials, then Create credentials, then API key.', 'swiftling' ), 'https://console.cloud.google.com/apis/credentials' ),
array(
__( 'Restrict the key: under API restrictions, allow only the Cloud Translation API.', 'swiftling' ),
'',
__( 'Under Application restrictions choose None, or IP addresses when your server has a fixed address; never Websites, because SwiftLing asks Google from your server. Google’s note that a service account is required is about the Gemini and Agent Platform APIs, not this one.', 'swiftling' ),
),
array( __( 'To never pay more than you expect, set a daily limit of characters under Quotas of the Cloud Translation API.', 'swiftling' ), 'https://console.cloud.google.com/apis/api/translate.googleapis.com/quotas' ),
array( __( 'Paste the key above and save it.', 'swiftling' ), '' ),
);
return array(
'title' => __( 'How to get a Google key', 'swiftling' ),
'intro' => __( 'Any Google account will do. It takes about ten minutes.', 'swiftling' ),
'steps' => $steps,
'link' => array( __( 'Google’s prices and the characters it translates free of charge each month', 'swiftling' ), 'https://cloud.google.com/translate/pricing' ),
);
}
/**
* Checks a key: asks for the languages Google translates, then translates one word, which also
* tells whether the project of the key has billing on. That costs two characters.
*
* @param string $key API key.
* @return array{languages: string[]}|WP_Error
*/
public function check( string $key ) {
$list = $this->request( $key, 'GET', '/languages', null );
if ( is_wp_error( $list ) ) {
return $list;
}
$languages = array();
foreach ( isset( $list['data']['languages'] ) && is_array( $list['data']['languages'] ) ? $list['data']['languages'] : array() as $language ) {
if ( isset( $language['language'] ) && is_string( $language['language'] ) ) {
$languages[] = $language['language'];
}
}
if ( ! $languages ) {
return new WP_Error( 'swiftling_service_answer', __( 'Google answered without a list of languages. Try again later.', 'swiftling' ) );
}
$test = $this->request(
$key,
'POST',
'',
array(
'q' => array( 'Hi' ),
'source' => 'en',
'target' => 'de',
'format' => 'text',
)
);
if ( is_wp_error( $test ) ) {
return $test;
}
Services::addUsage( $this->id(), 2 );
return array( 'languages' => $languages );
}
/**
* Returns Google's code for a language: the locale's own variant when Google has it, such as
* pt-PT or fr-CA, otherwise the language, such as de for de_DE. A locale with a script, such as
* sr_Latn, only matches that script: Google writes Serbian in Cyrillic.
*
* @param array $language Language as swiftling_languages() gives it.
*/
public function languageCode( array $language ): ?string {
$known = array();
foreach ( $this->languages() as $code ) {
$known[ strtolower( $code ) ] = $code;
}
if ( ! $known ) {
return null;
}
foreach ( self::candidates( (string) $language['locale'] ) as $candidate ) {
if ( isset( $known[ strtolower( $candidate ) ] ) ) {
return $known[ strtolower( $candidate ) ];
}
}
return null;
}
/**
* Returns the codes Google may use for a locale, the closest first.
*
* @param string $locale For example de_DE, pt_PT, sr_Latn_RS, zh_TW, nb_NO or cnr.
* @return string[]
*/
private static function candidates( string $locale ): array {
$parts = explode( '_', str_replace( '-', '_', $locale ) );
$lang = strtolower( array_shift( $parts ) );
$script = '';
$region = '';
foreach ( $parts as $part ) {
if ( 4 === strlen( $part ) && ctype_alpha( $part ) ) {
$script = ucfirst( strtolower( $part ) );
} elseif ( '' === $region && ( ( 2 === strlen( $part ) && ctype_alpha( $part ) ) || ( 3 === strlen( $part ) && ctype_digit( $part ) ) ) ) {
$region = strtoupper( $part );
}
// Other parts are WordPress variants, such as formal in de_DE_formal.
}
if ( 'zh' === $lang ) {
$traditional = 'Hant' === $script || ( '' === $script && in_array( $region, array( 'TW', 'HK', 'MO' ), true ) );
return $traditional ? array( 'zh-TW' ) : array( 'zh-CN', 'zh' );
}
if ( '' !== $script ) {
return array( $lang . '-' . $script );
}
// Languages Google knows under another code, or under two.
$aliases = array(
'nb' => array( 'no' ),
'nn' => array( 'no' ),
'he' => array( 'he', 'iw' ),
'jv' => array( 'jv', 'jw' ),
'fil' => array( 'fil', 'tl' ),
'tl' => array( 'tl', 'fil' ),
);
$codes = '' !== $region ? array( $lang . '-' . $region ) : array();
return array_values( array_unique( array_merge( $codes, isset( $aliases[ $lang ] ) ? $aliases[ $lang ] : array( $lang ) ) ) );
}
/**
* {@inheritDoc}
*
* @param array $items Key => text.
* @param string $source Google's code of the original language.
* @param string $target Google's code of the language to translate into.
*/
public function translate( array $items, string $source, string $target ) {
$key = $this->key();
if ( '' === $key ) {
return new WP_Error( 'swiftling_service_key', __( 'Google is not connected: add a key on the Translation service screen.', 'swiftling' ) );
}
$segments = array();
$tags = array();
$kept = array();
foreach ( $items as $item_key => $item ) {
$keep = isset( $item['keep'] ) && is_array( $item['keep'] ) ? array_filter( $item['keep'], 'is_string' ) : array();
if ( $item['html'] || $keep ) {
$html = $item['html'] ? $item['text'] : Markup::plainToHtml( $item['text'] );
// Tags first, so the spans of the parts to keep go to Google as they are.
list( $html, $tags[ $item_key ] ) = Markup::protect( $html );
list( $segments[ $item_key ], $kept[ $item_key ] ) = Markup::keep( $html, $item['html'] ? $keep : array_map( array( Markup::class, 'plainToHtml' ), $keep ) );
} else {
$segments[ $item_key ] = Markup::plainToHtml( $item['text'] );
}
}
$result = array(
'translations' => array(),
'failed' => array(),
'characters' => 0,
'error' => '',
);
$chunks = self::chunks( $segments );
foreach ( $chunks as $index => $chunk ) {
$answer = $this->request(
$key,
'POST',
'',
array(
'q' => array_values( $chunk ),
'source' => $source,
'target' => $target,
'format' => 'html',
'model' => 'nmt',
)
);
if ( is_wp_error( $answer ) ) {
if ( ! $result['translations'] ) {
return $answer;
}
// What Google translated so far stays; the rest is reported as not translated.
foreach ( array_slice( $chunks, $index ) as $rest ) {
array_push( $result['failed'], ...array_map( 'strval', array_keys( $rest ) ) );
}
$result['error'] = $answer->get_error_message();
break;
}
$result['characters'] += array_sum( array_map( 'mb_strlen', $chunk ) );
$translated = isset( $answer['data']['translations'] ) && is_array( $answer['data']['translations'] ) ? array_values( $answer['data']['translations'] ) : array();
$position = 0;
foreach ( array_keys( $chunk ) as $item_key ) {
$text = isset( $translated[ $position ]['translatedText'] ) && is_string( $translated[ $position ]['translatedText'] ) ? $translated[ $position ]['translatedText'] : null;
++$position;
if ( null !== $text && isset( $tags[ $item_key ] ) ) {
$text = Markup::restore( $text, $tags[ $item_key ] );
$text = null === $text ? null : Markup::unkeep( $text, $kept[ $item_key ] );
if ( null !== $text && ! $items[ $item_key ]['html'] ) {
$text = Markup::htmlToPlain( $text );
}
} elseif ( null !== $text ) {
$text = Markup::htmlToPlain( $text );
}
if ( null === $text || '' === trim( wp_strip_all_tags( $text ) ) ) {
$result['failed'][] = (string) $item_key;
} else {
$result['translations'][ (string) $item_key ] = $text;
}
}
}
return $result;
}
/**
* Splits texts into requests of at most CHUNK_TEXTS texts and, where possible, CHUNK_CHARACTERS
* characters. A longer text goes alone.
*
* @param array $segments Key => text as sent.
* @return array>
*/
private static function chunks( array $segments ): array {
$chunks = array();
$chunk = array();
$length = 0;
foreach ( $segments as $key => $segment ) {
$size = mb_strlen( $segment );
if ( $chunk && ( count( $chunk ) >= self::CHUNK_TEXTS || $length + $size > self::CHUNK_CHARACTERS ) ) {
$chunks[] = $chunk;
$chunk = array();
$length = 0;
}
$chunk[ $key ] = $segment;
$length += $size;
}
if ( $chunk ) {
$chunks[] = $chunk;
}
return $chunks;
}
/**
* Sends a request to the API with the key in a header, where it does not end up in logs of
* addresses.
*
* @param string $key API key.
* @param string $method GET or POST.
* @param string $path Path after the address of the API, e.g. "/languages".
* @param array|null $body JSON body of a POST request.
* @return array|WP_Error The answer, decoded.
*/
private function request( string $key, string $method, string $path, ?array $body ) {
$args = array(
'method' => $method,
'timeout' => 45,
'headers' => array( 'X-goog-api-key' => $key ),
);
if ( null !== $body ) {
$args['headers']['Content-Type'] = 'application/json; charset=utf-8';
$args['body'] = wp_json_encode( $body );
}
$response = wp_remote_request( self::ENDPOINT . $path, $args );
if ( is_wp_error( $response ) ) {
/* translators: %s: error message, e.g. a connection timeout. */
return new WP_Error( 'swiftling_service_unreachable', sprintf( __( 'Google could not be reached: %s', 'swiftling' ), $response->get_error_message() ), array( 'status' => 502 ) );
}
$code = (int) wp_remote_retrieve_response_code( $response );
$data = json_decode( (string) wp_remote_retrieve_body( $response ), true );
if ( 200 !== $code || ! is_array( $data ) ) {
return self::error( $code, is_array( $data ) ? $data : array() );
}
return $data;
}
/**
* Turns an error answer of the API into a message that says what to do. The reason comes from
* the error's details, e.g. API_KEY_INVALID; for a reason not known here, Google's own message.
*
* @param int $code HTTP status.
* @param array $data Answer, decoded.
*/
private static function error( int $code, array $data ): WP_Error {
$reason = '';
$message = isset( $data['error']['message'] ) && is_string( $data['error']['message'] ) ? $data['error']['message'] : '';
foreach ( isset( $data['error']['details'] ) && is_array( $data['error']['details'] ) ? $data['error']['details'] : array() as $detail ) {
if ( isset( $detail['reason'] ) && is_string( $detail['reason'] ) ) {
$reason = $detail['reason'];
break;
}
}
$messages = array(
'API_KEY_INVALID' => __( 'Google does not accept this key. Check that it was copied whole.', 'swiftling' ),
'SERVICE_DISABLED' => __( 'The Cloud Translation API is not enabled in the Google Cloud project of this key. Enable it, wait a few minutes and try again.', 'swiftling' ),
'BILLING_DISABLED' => __( 'The Google Cloud project of this key has no active billing account. Google asks for one even for the characters it translates free of charge.', 'swiftling' ),
'API_KEY_SERVICE_BLOCKED' => __( 'This key may not use the Cloud Translation API. Add it under the API restrictions of the key.', 'swiftling' ),
'API_KEY_HTTP_REFERRER_BLOCKED' => __( 'This key only accepts requests from websites in a browser, but SwiftLing asks Google from your server. Remove the website restriction of the key, or restrict it to the IP address of your server.', 'swiftling' ),
'API_KEY_IP_ADDRESS_BLOCKED' => __( 'This key does not accept requests from the IP address of your server. Check the IP address restriction of the key.', 'swiftling' ),
'RATE_LIMIT_EXCEEDED' => __( 'Google’s limit for this key is reached: a daily limit set for it, or too many requests at once. Try again later.', 'swiftling' ),
'RESOURCE_EXHAUSTED' => __( 'Google’s limit for this key is reached: a daily limit set for it, or too many requests at once. Try again later.', 'swiftling' ),
'CONSUMER_INVALID' => __( 'The Google Cloud project of this key no longer exists or cannot be used.', 'swiftling' ),
'API_KEY_ANDROID_APP_BLOCKED' => __( 'This key is restricted to apps. Restrict it to the IP address of your server instead.', 'swiftling' ),
'API_KEY_IOS_APP_BLOCKED' => __( 'This key is restricted to apps. Restrict it to the IP address of your server instead.', 'swiftling' ),
'ACCESS_TOKEN_SCOPE_INSUFFICIENT' => __( 'This key may not use the Cloud Translation API. Add it under the API restrictions of the key.', 'swiftling' ),
);
if ( isset( $messages[ $reason ] ) ) {
$text = $messages[ $reason ];
} elseif ( 429 === $code ) {
$text = $messages['RATE_LIMIT_EXCEEDED'];
} elseif ( '' !== $message ) {
/* translators: %s: Google's error message, in English. */
$text = sprintf( __( 'Google answered: %s', 'swiftling' ), $message );
} else {
/* translators: %d: HTTP status code. */
$text = sprintf( __( 'Google answered with error %d. Try again later.', 'swiftling' ), $code );
}
return new WP_Error(
'swiftling_service_' . ( '' !== $reason ? strtolower( $reason ) : 'error' ),
$text,
// A project without billing has a key that works on an account that cannot pay.
array( 'status' => 429 === $code ? 429 : 502 ) + ( 'BILLING_DISABLED' === $reason ? array( 'reason' => 'billing' ) : array() )
);
}
}