Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.44% covered (success)
98.44%
378 / 384
93.33% covered (success)
93.33%
28 / 30
CRAP
0.00% covered (danger)
0.00%
0 / 1
SelectVoice
98.43% covered (success)
98.43%
377 / 383
93.33% covered (success)
93.33%
28 / 30
103
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 element
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
3
 render_customize_toggle
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 get_language_code
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 get_voice_id
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 get_languages
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 get_voices_for_language
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 is_valid_language
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 language_names
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 accents_for_name
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 find_language_by_code
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 languages_for_script
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 render_language_name_select
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
2
 render_accent_select
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
4
 render_native_select
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
2
 render_model_select
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
6
 render_voice_select
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
7
 find_voice
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 voice_primary_code
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 voice_is_native
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 filter_voices_by_native
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
6
 default_native_filter
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 voice_model_key
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 language_models
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
7
 voice_model_label
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 render_loading_spinner
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 save
77.27% covered (warning)
77.27%
17 / 22
0.00% covered (danger)
0.00%
0 / 1
11.17
 rest_api_init_callback
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
1
 languages_rest_api_response
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 voices_rest_api_response
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare( strict_types = 1 );
4
5/**
6 * BeyondWords Component: Select Voice
7 *
8 * @package BeyondWords\Editor\Components
9 * @author  Stuart McAlpine <stu@beyondwords.io>
10 * @since   4.0.0
11 * @since   7.0.0 Refactored to BeyondWords namespace with snake_case methods.
12 */
13
14namespace BeyondWords\Editor\Components;
15
16/**
17 * SelectVoice
18 *
19 * @since 4.0.0
20 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
21 */
22defined( 'ABSPATH' ) || exit;
23
24class SelectVoice {
25
26    /**
27     * The only voice service whose voices carry a selectable `model_id`.
28     *
29     * @since 7.0.0
30     */
31    public const ELEVENLABS_SERVICE = 'ElevenLabs';
32
33    /**
34     * The model listed first in the Model dropdown.
35     *
36     * @since 7.0.0
37     */
38    public const DEFAULT_ELEVENLABS_VOICE_MODEL_ID = 'eleven_multilingual_v2';
39
40    /**
41     * Bucket key for voices without an ElevenLabs model_id (e.g. standard voices).
42     *
43     * @since 7.0.0
44     */
45    public const STANDARD_MODEL_KEY = 'standard';
46
47    /**
48     * Init.
49     *
50     * @since 4.0.0
51     * @since 6.0.0 Make static.
52     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
53     */
54    public static function init() {
55        add_action( 'rest_api_init', [ self::class, 'rest_api_init_callback'] );
56
57        add_action(
58            'wp_loaded',
59            function (): void {
60                $post_types = \BeyondWords\Settings\Utils::get_compatible_post_types();
61
62                if ( is_array( $post_types ) ) {
63                    foreach ( $post_types as $post_type ) {
64                        add_action( "save_post_{$post_type}", [ self::class, 'save'], 10 );
65                    }
66                }
67            }
68        );
69    }
70
71    /**
72     * HTML output for this component.
73     *
74     * @since 4.0.0
75     * @since 4.5.1 Hide element if no language data exists.
76     * @since 5.4.0 Always display all languages and associated voices.
77     * @since 6.0.0 Make static.
78     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
79     *
80     * @param \WP_Post $post The post object.
81     *
82     * @return string|null
83     */
84    public static function element( $post ) {
85        $language_code = self::get_language_code( $post->ID );
86        $voice_id      = self::get_voice_id( $post->ID );
87        $languages     = self::get_languages();
88        $voices        = self::get_voices_for_language( $language_code );
89
90        $native_filter   = self::default_native_filter( $voices, $language_code, $voice_id );
91        $filtered_voices = self::filter_voices_by_native( $voices, $language_code, $native_filter, $voice_id );
92
93        // "Customize" is opt-in: a post is customised once it has an explicit
94        // language or voice; when off we store nothing so project defaults apply.
95        $customize    = '' !== (string) $language_code || '' !== (string) $voice_id;
96        $fields_style = $customize ? '' : 'display: none;';
97
98        wp_nonce_field( 'beyondwords_select_voice', 'beyondwords_select_voice_nonce' );
99
100        self::render_customize_toggle( $customize );
101        ?>
102        <div id="beyondwords-metabox-select-voice--fields" style="<?php echo esc_attr( $fields_style ); ?>">
103        <?php
104        self::render_language_name_select( $languages, $language_code );
105        self::render_accent_select( $languages, $language_code );
106        self::render_native_select( $native_filter );
107        self::render_model_select( $filtered_voices, $voice_id );
108        self::render_voice_select( $filtered_voices, $voice_id );
109        self::render_loading_spinner();
110        ?>
111        </div>
112        <?php
113    }
114
115    /**
116     * Render the "Customize" toggle.
117     *
118     * When unchecked, classic-metabox.js hides the fields and clears the selects
119     * so save() removes the meta and the project defaults apply.
120     *
121     * @since 7.0.0
122     *
123     * @param bool $customize Whether Customize is currently enabled.
124     */
125    private static function render_customize_toggle( bool $customize ): void {
126        ?>
127        <p
128            id="beyondwords-metabox-select-voice--customize"
129            class="post-attributes-label-wrapper page-template-label-wrapper"
130        >
131            <label class="post-attributes-label" for="beyondwords_customize">
132                <?php esc_html_e( 'Customize', 'speechkit' ); ?>
133            </label>
134        </p>
135        <input
136            type="checkbox"
137            id="beyondwords_customize"
138            name="beyondwords_customize"
139            value="1"
140            <?php checked( $customize ); ?>
141        />
142        <?php
143    }
144
145    /**
146     * Get the language code for a post.
147     *
148     * @since 6.0.0
149     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
150     *
151     * @param int $post_id The post ID.
152     * @return string|false The language code or false if not set.
153     */
154    private static function get_language_code( int $post_id ) {
155        $post_language_code = get_post_meta( $post_id, 'beyondwords_language_code', true );
156        return $post_language_code ?: '';
157    }
158
159    /**
160     * Get the voice ID for a post.
161     *
162     * @since 6.0.0
163     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
164     *
165     * @param int $post_id The post ID.
166     * @return string|false The voice ID or false if not set.
167     */
168    private static function get_voice_id( int $post_id ) {
169        $post_voice_id = get_post_meta( $post_id, 'beyondwords_body_voice_id', true );
170        return $post_voice_id ?: '';
171    }
172
173    /**
174     * Get all available languages.
175     *
176     * Coerced to a list of language records so an API failure (null/false, or a
177     * decoded error body) degrades to an empty dropdown instead of a TypeError.
178     *
179     * @since 7.0.0
180     *
181     * @return array The languages array, or an empty array on API failure.
182     */
183    private static function get_languages(): array {
184        $languages = \BeyondWords\Api\Client::get_languages();
185
186        if ( ! is_array( $languages ) ) {
187            return [];
188        }
189
190        return array_values( array_filter( $languages, 'is_array' ) );
191    }
192
193    /**
194     * Get voices for a language code.
195     *
196     * Coerced to a list of voice records so an API failure (null/false, or a
197     * decoded error body like a 429's) degrades to empty instead of fataling.
198     *
199     * @since 6.0.0
200     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
201     * @since 7.0.0 Drop non-array elements so an API error body can't fatal the render.
202     *
203     * @param string|false $language_code The language code.
204     * @return array The voices array, or an empty array on API failure.
205     */
206    private static function get_voices_for_language( $language_code ): array {
207        if ( $language_code === false || $language_code === '' ) {
208            return [];
209        }
210
211        $voices = \BeyondWords\Api\Client::get_voices( $language_code );
212
213        if ( ! is_array( $voices ) ) {
214            return [];
215        }
216
217        return array_values( array_filter( $voices, 'is_array' ) );
218    }
219
220    /**
221     * Whether a language row carries the fields the pickers need.
222     *
223     * @since 7.0.0
224     *
225     * @param mixed $language A language record.
226     *
227     * @return bool
228     */
229    private static function is_valid_language( $language ): bool {
230        return is_array( $language )
231            && ! empty( $language['code'] )
232            && ! empty( $language['name'] )
233            && ! empty( $language['accent'] );
234    }
235
236    /**
237     * The distinct language names across the language rows, in API order.
238     *
239     * @since 7.0.0
240     *
241     * @param array $languages The languages array.
242     *
243     * @return string[] The language names.
244     */
245    public static function language_names( array $languages ): array {
246        $names = [];
247
248        foreach ( $languages as $language ) {
249            if ( ! self::is_valid_language( $language ) ) {
250                continue;
251            }
252            if ( ! in_array( $language['name'], $names, true ) ) {
253                $names[] = $language['name'];
254            }
255        }
256
257        return $names;
258    }
259
260    /**
261     * The language rows (accents) for a language name, in API order.
262     *
263     * @since 7.0.0
264     *
265     * @param array  $languages The languages array.
266     * @param string $name      The language name.
267     *
268     * @return array The matching language rows.
269     */
270    public static function accents_for_name( array $languages, string $name ): array {
271        if ( '' === $name ) {
272            return [];
273        }
274
275        return array_values(
276            array_filter(
277                $languages,
278                static function ( $language ) use ( $name ) {
279                    return self::is_valid_language( $language ) && $language['name'] === $name;
280                }
281            )
282        );
283    }
284
285    /**
286     * Find a language row by its code.
287     *
288     * @since 7.0.0
289     *
290     * @param array        $languages The languages array.
291     * @param string|false $code      The language code.
292     *
293     * @return array|null The matching language row, or null.
294     */
295    public static function find_language_by_code( array $languages, $code ): ?array {
296        foreach ( $languages as $language ) {
297            if ( self::is_valid_language( $language ) && strval( $language['code'] ) === strval( $code ) ) {
298                return $language;
299            }
300        }
301
302        return null;
303    }
304
305    /**
306     * Slim language rows for the classic-editor script, so it can rebuild the
307     * Accent dropdown client-side without another API round-trip.
308     *
309     * @since 7.0.0
310     *
311     * @return array The slim language rows.
312     */
313    public static function languages_for_script(): array {
314        $rows = [];
315
316        foreach ( self::get_languages() as $language ) {
317            if ( ! self::is_valid_language( $language ) ) {
318                continue;
319            }
320
321            $rows[] = [
322                'code'           => strval( $language['code'] ),
323                'name'           => strval( $language['name'] ),
324                'accent'         => strval( $language['accent'] ),
325                'defaultVoiceId' => strval( $language['default_voices']['body']['id'] ?? '' ),
326            ];
327        }
328
329        return $rows;
330    }
331
332    /**
333     * Render the Language select: one entry per language NAME (e.g. "English").
334     * Carries no `name` attribute, so it is not submitted â€” the Accent select
335     * holds the `beyondwords_language_code` field.
336     *
337     * @since 6.0.0 As render_language_select, combining name + accent.
338     * @since 7.0.0 Split into Language (name) + Accent selects.
339     *
340     * @param array        $languages The languages array.
341     * @param string|false $selected_lang_code The selected language code.
342     */
343    private static function render_language_name_select( array $languages, $selected_lang_code ): void {
344        $selected_language = self::find_language_by_code( $languages, $selected_lang_code );
345        $selected_name     = $selected_language['name'] ?? '';
346        ?>
347        <p
348            id="beyondwords-metabox-select-voice--language-name"
349            class="post-attributes-label-wrapper page-template-label-wrapper"
350        >
351            <label class="post-attributes-label" for="beyondwords_language_name">
352                <?php esc_html_e( 'Language', 'speechkit' ); ?>
353            </label>
354        </p>
355        <select id="beyondwords_language_name" style="width: 100%;">
356            <?php
357            printf(
358                '<option value="" %s>%s</option>',
359                selected( '', strval( $selected_name ), false ),
360                esc_html__( 'Select a language…', 'speechkit' )
361            );
362            foreach ( self::language_names( $languages ) as $name ) {
363                printf(
364                    '<option value="%s" %s>%s</option>',
365                    esc_attr( $name ),
366                    selected( $name, strval( $selected_name ), false ),
367                    esc_html( $name )
368                );
369            }
370            ?>
371        </select>
372        <?php
373    }
374
375    /**
376     * Render the Accent select â€” the submitted `beyondwords_language_code` field,
377     * since a (name, accent) pair maps to exactly one language code. Hidden, not
378     * omitted, for single-accent languages, and always renders an option because
379     * save() requires the key to be posted.
380     *
381     * @since 7.0.0
382     *
383     * @param array        $languages The languages array.
384     * @param string|false $selected_lang_code The selected language code.
385     */
386    private static function render_accent_select( array $languages, $selected_lang_code ): void {
387        $selected_language = self::find_language_by_code( $languages, $selected_lang_code );
388        $selected_name     = $selected_language['name'] ?? '';
389        $accents           = self::accents_for_name( $languages, strval( $selected_name ) );
390
391        $show_accent  = count( $accents ) > 1;
392        $accent_style = $show_accent ? '' : 'display: none;';
393        ?>
394        <div
395            id="beyondwords-metabox-select-voice--accent"
396            class="beyondwords-metabox-settings__field"
397            style="<?php echo esc_attr( $accent_style ); ?>"
398        >
399            <p class="post-attributes-label-wrapper page-template-label-wrapper">
400                <label class="post-attributes-label" for="beyondwords_language_code">
401                    <?php esc_html_e( 'Accent', 'speechkit' ); ?>
402                </label>
403            </p>
404            <select id="beyondwords_language_code" name="beyondwords_language_code" style="width: 100%;">
405                <?php
406                if ( empty( $accents ) ) {
407                    echo '<option value=""></option>';
408                }
409                foreach ( $accents as $language ) {
410                    printf(
411                        '<option value="%s" data-default-voice-id="%s" %s>%s</option>',
412                        esc_attr( $language['code'] ),
413                        esc_attr( $language['default_voices']['body']['id'] ?? '' ),
414                        selected( strval( $language['code'] ), strval( $selected_lang_code ), false ),
415                        esc_html( $language['accent'] )
416                    );
417                }
418                ?>
419            </select>
420        </div>
421        <?php
422    }
423
424    /**
425     * Render the Native select: filter the Voice list to voices native to the
426     * language, or all. Carries no `name`, so it is not submitted.
427     *
428     * @since 7.0.0
429     *
430     * @param string $native_filter The selected filter ("native" or "all").
431     */
432    private static function render_native_select( string $native_filter ): void {
433        $options = [
434            'native' => __( 'Native', 'speechkit' ),
435            'all'    => __( 'All', 'speechkit' ),
436        ];
437        ?>
438        <div
439            id="beyondwords-metabox-select-voice--native"
440            class="beyondwords-metabox-settings__field"
441        >
442            <p class="post-attributes-label-wrapper page-template-label-wrapper">
443                <label class="post-attributes-label" for="beyondwords_native">
444                    <?php esc_html_e( 'Native', 'speechkit' ); ?>
445                </label>
446            </p>
447            <select id="beyondwords_native" style="width: 100%;">
448                <?php
449                foreach ( $options as $value => $label ) {
450                    printf(
451                        '<option value="%s" %s>%s</option>',
452                        esc_attr( $value ),
453                        selected( $value, $native_filter, false ),
454                        esc_html( $label )
455                    );
456                }
457                ?>
458            </select>
459        </div>
460        <?php
461    }
462
463    /**
464     * Render the Model select: a language-level filter over the voices.
465     *
466     * Carries no `name` â€” it is never submitted; the persisted value is the
467     * voice id. Hidden when a language offers a single bucket.
468     *
469     * @since 7.0.0
470     *
471     * @param array        $voices The voices array.
472     * @param string|false $selected_voice_id The selected voice ID.
473     */
474    private static function render_model_select( array $voices, $selected_voice_id ): void {
475        $selected_voice = self::find_voice( $voices, $selected_voice_id );
476        $selected_key   = $selected_voice ? self::voice_model_key( $selected_voice ) : '';
477
478        $models     = self::language_models( $voices );
479        $show_model = count( $models ) > 1;
480        ?>
481        <div
482            id="beyondwords-metabox-select-voice--model"
483            class="beyondwords-metabox-settings__field"
484            <?php echo $show_model ? '' : 'style="display: none;"'; ?>
485        >
486            <p class="post-attributes-label-wrapper page-template-label-wrapper">
487                <label class="post-attributes-label" for="beyondwords_model">
488                    <?php esc_html_e( 'Model', 'speechkit' ); ?>
489                </label>
490            </p>
491            <select id="beyondwords_model" style="width: 100%;">
492                <?php
493                printf(
494                    '<option value="" %s>%s</option>',
495                    selected( '', $show_model ? strval( $selected_key ) : '', false ),
496                    esc_html__( 'Select a model', 'speechkit' )
497                );
498                foreach ( $models as $model ) {
499                    printf(
500                        '<option value="%s" %s>%s</option>',
501                        esc_attr( $model['key'] ),
502                        selected( strval( $model['key'] ), $show_model ? strval( $selected_key ) : '', false ),
503                        esc_html( $model['label'] )
504                    );
505                }
506                ?>
507            </select>
508        </div>
509        <?php
510    }
511
512    /**
513     * Render the Voice select: the voices in the currently selected model bucket.
514     *
515     * The saved field (`beyondwords_voice_id`) â€” the submitted voice id carries
516     * the model.
517     *
518     * @since 6.0.0
519     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
520     * @since 7.0.0 Model-first: scope the Voice list to the selected model.
521     *
522     * @param array        $voices The voices array.
523     * @param string|false $selected_voice_id The selected voice ID.
524     */
525    private static function render_voice_select( array $voices, $selected_voice_id ): void {
526        $selected_voice = self::find_voice( $voices, $selected_voice_id );
527        $selected_key   = $selected_voice ? self::voice_model_key( $selected_voice ) : '';
528
529        $models     = self::language_models( $voices );
530        $show_model = count( $models ) > 1;
531
532        if ( $show_model ) {
533            $bucket_voices = array_values(
534                array_filter(
535                    $voices,
536                    static function ( $voice ) use ( $selected_key ) {
537                        return self::voice_model_key( $voice ) === $selected_key;
538                    }
539                )
540            );
541        } else {
542            $bucket_voices = array_values( $voices );
543        }
544
545        // Model gates the Voice list: hide it until a model is chosen.
546        $show_voice  = count( $voices ) > 0 && ( ! $show_model || '' !== strval( $selected_key ) );
547        $voice_style = $show_voice ? '' : 'display: none;';
548        ?>
549        <div
550            id="beyondwords-metabox-select-voice--voice-id"
551            class="beyondwords-metabox-settings__field"
552            style="<?php echo esc_attr( $voice_style ); ?>"
553        >
554            <p class="post-attributes-label-wrapper page-template-label-wrapper">
555                <label class="post-attributes-label" for="beyondwords_voice_id">
556                    <?php esc_html_e( 'Voice', 'speechkit' ); ?>
557                </label>
558            </p>
559            <select id="beyondwords_voice_id" name="beyondwords_voice_id" style="width: 100%;">
560                <?php
561                printf(
562                    '<option value="" %s>%s</option>',
563                    selected( '', strval( $selected_voice_id ), false ),
564                    esc_html__( 'Select a voice', 'speechkit' )
565                );
566                foreach ( $bucket_voices as $voice ) {
567                    printf(
568                        '<option value="%s" %s>%s</option>',
569                        esc_attr( $voice['id'] ?? '' ),
570                        selected( strval( $voice['id'] ?? '' ), strval( $selected_voice_id ), false ),
571                        esc_html( $voice['name'] ?? '' )
572                    );
573                }
574                ?>
575            </select>
576        </div>
577        <?php
578    }
579
580    /**
581     * Find a voice record by id.
582     *
583     * @since 7.0.0
584     *
585     * @param array        $voices   The voices array.
586     * @param string|false $voice_id The voice ID to find.
587     *
588     * @return array|null The matching voice record, or null.
589     */
590    private static function find_voice( array $voices, $voice_id ): ?array {
591        foreach ( $voices as $voice ) {
592            if ( strval( $voice['id'] ?? '' ) === strval( $voice_id ) ) {
593                return $voice;
594            }
595        }
596        return null;
597    }
598
599    /**
600     * A voice's primary (native) language code. The API returns `language` as
601     * either a string or a `{ code }` object, with `languages[]` as a fallback.
602     *
603     * @since 7.0.0
604     *
605     * @param array $voice A voice record.
606     *
607     * @return string The primary language code, or '' when unknown.
608     */
609    public static function voice_primary_code( array $voice ): string {
610        $language = $voice['language'] ?? null;
611
612        if ( is_string( $language ) ) {
613            return $language;
614        }
615        if ( is_array( $language ) && ! empty( $language['code'] ) ) {
616            return (string) $language['code'];
617        }
618        return (string) ( $voice['languages'][0]['code'] ?? '' );
619    }
620
621    /**
622     * Whether a language code is a voice's primary language. A voice with no
623     * determinable primary language counts as native, so it is never hidden.
624     *
625     * @since 7.0.0
626     *
627     * @param array  $voice A voice record.
628     * @param string $code  The language code.
629     *
630     * @return bool
631     */
632    public static function voice_is_native( array $voice, string $code ): bool {
633        $primary = self::voice_primary_code( $voice );
634        if ( '' === $primary ) {
635            return true;
636        }
637        return $primary === $code;
638    }
639
640    /**
641     * Apply the Native filter to a language's voices. `$keep_id` is always kept
642     * so the saved voice is never dropped from the list.
643     *
644     * @since 7.0.0
645     *
646     * @param array        $voices        The voices array.
647     * @param string       $code          The language code.
648     * @param string       $native_filter "native" or "all".
649     * @param string|false $keep_id       The voice id to always keep.
650     *
651     * @return array The filtered voices.
652     */
653    public static function filter_voices_by_native( array $voices, string $code, string $native_filter, $keep_id ): array {
654        if ( 'all' === $native_filter ) {
655            $result = $voices;
656        } else {
657            $result = array_values(
658                array_filter(
659                    $voices,
660                    static function ( $voice ) use ( $code ) {
661                        return self::voice_is_native( $voice, $code );
662                    }
663                )
664            );
665        }
666
667        $keep_id = strval( $keep_id );
668        if ( '' === $keep_id ) {
669            return $result;
670        }
671
672        foreach ( $result as $voice ) {
673            if ( strval( $voice['id'] ?? '' ) === $keep_id ) {
674                return $result;
675            }
676        }
677
678        $saved = self::find_voice( $voices, $keep_id );
679        if ( $saved ) {
680            $result[] = $saved;
681        }
682
683        return $result;
684    }
685
686    /**
687     * The Native filter to open with: "all" when the saved voice is not native
688     * to the language (so it stays visible), otherwise "native".
689     *
690     * @since 7.0.0
691     *
692     * @param array        $voices        The voices array.
693     * @param string|false $language_code The language code.
694     * @param string|false $voice_id      The saved voice id.
695     *
696     * @return string "native" or "all".
697     */
698    public static function default_native_filter( array $voices, $language_code, $voice_id ): string {
699        $saved = self::find_voice( $voices, $voice_id );
700
701        if ( $saved && '' !== strval( $language_code ) && ! self::voice_is_native( $saved, strval( $language_code ) ) ) {
702            return 'all';
703        }
704
705        return 'native';
706    }
707
708    /**
709     * The model bucket key for a voice: its ElevenLabs model_id, else Standard.
710     *
711     * Accepts any value so a scalar from a decoded API error body buckets as
712     * Standard rather than fataling. Mirrors settings-panel/helpers.js.
713     *
714     * @since 7.0.0
715     *
716     * @param mixed $voice A voice record (defensively, any value).
717     *
718     * @return string The model bucket key.
719     */
720    public static function voice_model_key( mixed $voice ): string {
721        if (
722            is_array( $voice ) &&
723            ( $voice['service'] ?? '' ) === self::ELEVENLABS_SERVICE &&
724            isset( $voice['model_id'] ) &&
725            is_string( $voice['model_id'] )
726        ) {
727            return $voice['model_id'];
728        }
729        return self::STANDARD_MODEL_KEY;
730    }
731
732    /**
733     * The distinct model buckets across a language's voices, for the Model dropdown.
734     *
735     * ElevenLabs models first (the default leading), then a single Standard
736     * bucket if present. Mirrors getLanguageModels() in settings-panel/helpers.js.
737     *
738     * @since 7.0.0
739     *
740     * @param array $voices All voices for the current language.
741     *
742     * @return array The Model dropdown options.
743     */
744    public static function language_models( array $voices ): array {
745        $model_ids    = [];
746        $has_standard = false;
747
748        foreach ( $voices as $voice ) {
749            $key = self::voice_model_key( $voice );
750            if ( self::STANDARD_MODEL_KEY === $key ) {
751                $has_standard = true;
752            } elseif ( ! in_array( $key, $model_ids, true ) ) {
753                $model_ids[] = $key;
754            }
755        }
756
757        // Stable sort (PHP 8+): the default model leads, the rest keep API order.
758        usort(
759            $model_ids,
760            static function ( $a, $b ) {
761                if ( $a === self::DEFAULT_ELEVENLABS_VOICE_MODEL_ID ) {
762                    return -1;
763                }
764                if ( $b === self::DEFAULT_ELEVENLABS_VOICE_MODEL_ID ) {
765                    return 1;
766                }
767                return 0;
768            }
769        );
770
771        $models = array_map(
772            static function ( $key ) {
773                return [
774                    'key'   => $key,
775                    'label' => self::voice_model_label( $key ),
776                ];
777            },
778            $model_ids
779        );
780
781        if ( $has_standard ) {
782            $models[] = [
783                'key'   => self::STANDARD_MODEL_KEY,
784                'label' => __( 'Legacy', 'speechkit' ),
785            ];
786        }
787
788        return $models;
789    }
790
791    /**
792     * Human label for a voice model_id slug.
793     *
794     * Mirrors voiceModelLabel() in settings-panel/helpers.js.
795     *
796     * @since 7.0.0
797     *
798     * @param string $model_id The model_id slug (e.g. `eleven_flash_v2_5`).
799     *
800     * @return string A display label.
801     */
802    public static function voice_model_label( string $model_id ): string {
803        $labels = [
804            'eleven_v3'              => __( 'v3', 'speechkit' ),
805            'eleven_multilingual_v2' => __( 'Multilingual v2', 'speechkit' ),
806            'eleven_flash_v2_5'      => __( 'Flash v2.5', 'speechkit' ),
807            'eleven_turbo_v2_5'      => __( 'Turbo v2.5', 'speechkit' ),
808        ];
809
810        if ( isset( $labels[ $model_id ] ) ) {
811            return $labels[ $model_id ];
812        }
813
814        $slug = preg_replace( '/^eleven_/', '', $model_id );
815        $slug = str_replace( '_', ' ', (string) $slug );
816
817        return ucwords( $slug );
818    }
819
820    /**
821     * Render the loading spinner.
822     *
823     * @since 6.0.0
824     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
825     */
826    private static function render_loading_spinner(): void {
827        ?>
828        <img
829            src="/wp-admin/images/spinner.gif"
830            class="beyondwords-settings__loader"
831            style="display:none; padding: 3px 0;"
832        />
833        <?php
834    }
835
836    /**
837     * Save the meta when the post is saved.
838     *
839     * @since 4.0.0
840     * @since 6.0.0 Make static.
841     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
842     *
843     * @param int $post_id The ID of the post being saved.
844     */
845    public static function save( $post_id ) {
846        if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
847            return $post_id;
848        }
849
850        // "save_post" can be triggered at other times, so verify this request came from the our component
851        if (
852            ! isset( $_POST['beyondwords_language_code'] ) ||
853            ! isset( $_POST['beyondwords_voice_id'] ) ||
854            ! isset( $_POST['beyondwords_select_voice_nonce'] )
855        ) {
856            return $post_id;
857        }
858
859        if (
860            ! wp_verify_nonce(
861                sanitize_key( $_POST['beyondwords_select_voice_nonce'] ),
862                'beyondwords_select_voice'
863            )
864        ) {
865            return $post_id;
866        }
867
868        if ( ! current_user_can( 'edit_post', $post_id ) ) {
869            return $post_id;
870        }
871
872        $language_code = sanitize_text_field( wp_unslash( $_POST['beyondwords_language_code'] ) );
873
874        if ( ! empty( $language_code ) ) {
875            update_post_meta( $post_id, 'beyondwords_language_code', $language_code );
876        } else {
877            delete_post_meta( $post_id, 'beyondwords_language_code' );
878        }
879
880        $voice_id = sanitize_text_field( wp_unslash( $_POST['beyondwords_voice_id'] ) );
881
882        if ( ! empty( $voice_id ) ) {
883            update_post_meta( $post_id, 'beyondwords_body_voice_id', $voice_id );
884        } else {
885            delete_post_meta( $post_id, 'beyondwords_body_voice_id' );
886        }
887
888        return $post_id;
889    }
890
891    /**
892     * Register WP REST API route
893     *
894     * @since 4.0.0
895     * @since 6.0.0 Make static.
896     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
897     *
898     * @return void
899     */
900    public static function rest_api_init_callback() {
901        register_rest_route(
902            'beyondwords/v1',
903            '/languages',
904            [
905                'methods'             => \WP_REST_Server::READABLE,
906                'callback'            => [ self::class, 'languages_rest_api_response'],
907                'permission_callback' => fn() => current_user_can( 'edit_posts' ),
908            ]
909        );
910
911        register_rest_route(
912            'beyondwords/v1',
913            '/languages/(?P<languageCode>[a-zA-Z0-9-_]+)/voices',
914            [
915                'methods'             => \WP_REST_Server::READABLE,
916                'callback'            => [ self::class, 'voices_rest_api_response'],
917                'permission_callback' => fn() => current_user_can( 'edit_posts' ),
918            ]
919        );
920    }
921
922    /**
923     * "Languages" WP REST API response (required for the Gutenberg editor).
924     *
925     * @since 4.0.0
926     * @since 5.4.0 No longer filter by "Languages" plugin setting.
927     * @since 6.0.0 Make static.
928     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
929     *
930     * @return \WP_REST_Response
931     */
932    public static function languages_rest_api_response() {
933        $languages = \BeyondWords\Api\Client::get_languages();
934
935        return new \WP_REST_Response( $languages );
936    }
937
938    /**
939     * "Voices" WP REST API response (required for the Gutenberg editor).
940     *
941     * @since 4.0.0
942     * @since 6.0.0 Make static.
943     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
944     *
945     * @return \WP_REST_Response
946     */
947    public static function voices_rest_api_response( \WP_REST_Request $data ) {
948        $params = $data->get_url_params();
949
950        $voices = \BeyondWords\Api\Client::get_voices( $params['languageCode'] );
951
952        return new \WP_REST_Response( $voices );
953    }
954}