$strings * @param array $kinds Same keys as $strings. * @param array{hints?: array, context?: array} $options * hints: one note per string (same keys); context: title, type, outline, style. * @return array|WP_Error Same keys as $strings. */ public function translate_strings( array $strings, array $kinds, string $from, string $to, ?Provider $provider = null, array $options = array() ): array|WP_Error { $settings = $this->settings->get(); $from_lang = $settings->language( $from ); $to_lang = $settings->language( $to ); if ( ! $from_lang || ! $to_lang ) { return new WP_Error( 'blocklingo_bad_lang', __( 'Unknown language.', 'blocklingo' ) ); } $provider = $provider ?? $this->providers->make(); if ( is_wp_error( $provider ) ) { return $provider; } $unique = array(); // Payload => index of the first occurrence. $plan = array(); // Key => [ lead, payload|null, trail, kind, original ]. $hints = (array) ( $options['hints'] ?? array() ); $notes = array(); // One note per unique payload. foreach ( $strings as $k => $s ) { $s = (string) $s; $kind = $kinds[ $k ] ?? 'html'; if ( ! BlockTexts::has_text( $s ) ) { $plan[ $k ] = array( '', null, '', $kind, $s ); continue; } preg_match( '/^(\s*)(.*?)(\s*)$/su', $s, $m ); $payload = 'text' === $kind ? esc_html( $m[2] ) : $m[2]; if ( ! isset( $unique[ $payload ] ) ) { $notes[] = (string) ( $hints[ $k ] ?? '' ); $unique[ $payload ] = count( $unique ); } $plan[ $k ] = array( $m[1], $payload, $m[3], $kind, $s ); } $translated = array(); if ( $unique ) { $context_in = (array) ( $options['context'] ?? array() ); if ( ! isset( $context_in['style'] ) ) { $context_in['style'] = $settings->style_for( $to ); } $context = $this->build_context( $context_in )->with_notes( $notes ); $translated = $provider->translate( array_map( 'strval', array_keys( $unique ) ), $from_lang, $to_lang, $context ); if ( is_wp_error( $translated ) ) { return $translated; } } $out = array(); foreach ( $plan as $k => $p ) { list( $lead, $payload, $trail, $kind, $orig ) = $p; if ( null === $payload ) { $out[ $k ] = $orig; continue; } $t = $translated[ $unique[ $payload ] ]; if ( 'text' === $kind ) { $t = html_entity_decode( $t, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); } $out[ $k ] = $lead . MarkupGuard::clean( $t, $orig ) . $trail; } return $out; } /** * Translate block markup that is not a post (template parts, patterns). * * @param array $context title, type. */ public function translate_content( string $content, string $from, string $to, array $context = array() ): string|WP_Error { $blocks = parse_blocks( $content ); $found = BlockTexts::collect( $blocks ); if ( ! $found->refs ) { return $content; } $strings = array(); $hints = array(); $kinds = array(); foreach ( $found->texts() as $i => $text ) { $strings[ 'c' . $i ] = $text; $hints[ 'c' . $i ] = $found->where[ $i ]; $kinds[ 'c' . $i ] = $found->kinds[ $i ]; } $context['outline'] = implode( "\n", $found->texts() ); $result = $this->translate_strings( $strings, $kinds, $from, $to, null, array( 'hints' => $hints, 'context' => $context, ) ); if ( is_wp_error( $result ) ) { return $result; } foreach ( array_keys( $found->refs ) as $i ) { $found->refs[ $i ] = $result[ 'c' . $i ]; } return serialize_blocks( $blocks ); } /** * Title, excerpt and block content of a post, in one provider pass. * * @return array{title: string, excerpt: string, content: string}|WP_Error */ public function translate_post( WP_Post $post, string $from, string $to ): array|WP_Error { $blocks = parse_blocks( $post->post_content ); $found = BlockTexts::collect( $blocks ); $strings = array(); $hints = array( 'title' => 'Page title', 'excerpt' => 'Excerpt', ); $kinds = array( 'title' => 'text', 'excerpt' => 'text', ); foreach ( $found->texts() as $i => $text ) { $strings[ 'c' . $i ] = $text; $hints[ 'c' . $i ] = $found->where[ $i ]; $kinds[ 'c' . $i ] = $found->kinds[ $i ]; } $strings['title'] = $post->post_title; $strings['excerpt'] = $post->post_excerpt; $type = get_post_type_object( $post->post_type ); $result = $this->translate_strings( $strings, $kinds, $from, $to, null, array( 'hints' => $hints, 'context' => array( 'title' => $post->post_title, 'type' => $type ? (string) $type->labels->singular_name : $post->post_type, 'outline' => implode( "\n", $found->texts() ), ), ) ); if ( is_wp_error( $result ) ) { return $result; } foreach ( array_keys( $found->refs ) as $i ) { $found->refs[ $i ] = $result[ 'c' . $i ]; } return array( 'title' => $result['title'], 'excerpt' => $result['excerpt'], 'content' => serialize_blocks( $blocks ), ); } /** * Page context for AI providers: the site name added, the outline as plain * text capped at blocklingo_context_max_chars (6000). * * @param array $c title, type, outline, style. */ public function build_context( array $c ): Context { $site = trim( wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ) ); $desc = trim( wp_specialchars_decode( (string) get_bloginfo( 'description' ), ENT_QUOTES ) ); $outline = ''; if ( ! empty( $c['outline'] ) ) { $plain = html_entity_decode( wp_strip_all_tags( (string) $c['outline'] ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ); $lines = array_filter( array_map( static fn( string $l ): string => trim( (string) preg_replace( '/\s+/u', ' ', $l ) ), (array) preg_split( '/\r\n|\r|\n/', $plain ) ), static fn( string $s ): bool => '' !== $s ); $outline = implode( "\n", $lines ); $max = (int) apply_filters( 'blocklingo_context_max_chars', 6000 ); if ( mb_strlen( $outline ) > $max ) { $outline = mb_substr( $outline, 0, $max ) . ' …'; } } return new Context( $desc ? $site . ' - ' . $desc : $site, sanitize_text_field( (string) ( $c['title'] ?? '' ) ), sanitize_text_field( (string) ( $c['type'] ?? '' ) ), $outline, sanitize_textarea_field( (string) ( $c['style'] ?? '' ) ) ); } }