Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
70.90% covered (warning)
70.90%
95 / 134
53.85% covered (danger)
53.85%
7 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
SelectVoice
70.68% covered (warning)
70.68%
94 / 133
53.85% covered (danger)
53.85%
7 / 13
68.68
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 element
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 getLanguageCode
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getVoiceId
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getVoicesForLanguage
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 renderLanguageSelect
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
5
 renderVoiceSelect
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
2
 renderLoadingSpinner
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 save
75.00% covered (warning)
75.00%
18 / 24
0.00% covered (danger)
0.00%
0 / 1
10.27
 restApiInit
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 languagesRestApiResponse
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 voicesRestApiResponse
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 adminEnqueueScripts
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3declare(strict_types=1);
4
5/**
6 * BeyondWords Component: Select Voice
7 *
8 * @package Beyondwords\Wordpress
9 * @author  Stuart McAlpine <stu@beyondwords.io>
10 * @since   4.0.0
11 */
12
13namespace Beyondwords\Wordpress\Component\Post\SelectVoice;
14
15use Beyondwords\Wordpress\Core\CoreUtils;
16use Beyondwords\Wordpress\Component\Settings\SettingsUtils;
17use Beyondwords\Wordpress\Core\ApiClient;
18
19/**
20 * SelectVoice
21 *
22 * @since 4.0.0
23 */
24defined('ABSPATH') || exit;
25
26class SelectVoice
27{
28    /**
29     * Init.
30     *
31     * @since 4.0.0
32     * @since 6.0.0 Make static.
33     */
34    public static function init()
35    {
36        add_action('rest_api_init', [self::class, 'restApiInit']);
37        add_action('admin_enqueue_scripts', [self::class, 'adminEnqueueScripts']);
38
39        add_action('wp_loaded', function (): void {
40            $postTypes = SettingsUtils::getCompatiblePostTypes();
41
42            if (is_array($postTypes)) {
43                foreach ($postTypes as $postType) {
44                    add_action("save_post_{$postType}", [self::class, 'save'], 10);
45                }
46            }
47        });
48    }
49
50    /**
51     * HTML output for this component.
52     *
53     * @since 4.0.0
54     * @since 4.5.1 Hide element if no language data exists.
55     * @since 5.4.0 Always display all languages and associated voices.
56     * @since 6.0.0 Make static.
57     *
58     * @param \WP_Post $post The post object.
59     *
60     * @return string|null
61     */
62    public static function element($post)
63    {
64        $languageCode = self::getLanguageCode($post->ID);
65        $voiceId = self::getVoiceId($post->ID);
66        $languages = ApiClient::getLanguages();
67        $voices = self::getVoicesForLanguage($languageCode);
68
69        wp_nonce_field('beyondwords_select_voice', 'beyondwords_select_voice_nonce');
70
71        self::renderLanguageSelect($languages, $languageCode);
72        self::renderVoiceSelect($voices, $voiceId, $languageCode);
73        self::renderLoadingSpinner();
74    }
75
76    /**
77     * Get the language code for a post.
78     *
79     * @since 6.0.0
80     *
81     * @param int $postId The post ID.
82     * @return string|false The language code or false if not set.
83     */
84    private static function getLanguageCode(int $postId)
85    {
86        $postLanguageCode = get_post_meta($postId, 'beyondwords_language_code', true);
87        return $postLanguageCode ?: get_option('beyondwords_project_language_code');
88    }
89
90    /**
91     * Get the voice ID for a post.
92     *
93     * @since 6.0.0
94     *
95     * @param int $postId The post ID.
96     * @return string|false The voice ID or false if not set.
97     */
98    private static function getVoiceId(int $postId)
99    {
100        $postVoiceId = get_post_meta($postId, 'beyondwords_body_voice_id', true);
101        return $postVoiceId ?: get_option('beyondwords_project_body_voice_id');
102    }
103
104    /**
105     * Get voices for a language code.
106     *
107     * @since 6.0.0
108     *
109     * @param string|false $languageCode The language code.
110     * @return array The voices array.
111     */
112    private static function getVoicesForLanguage($languageCode): array
113    {
114        if ($languageCode === false || $languageCode === '') {
115            return [];
116        }
117
118        $voices = ApiClient::getVoices($languageCode);
119        return is_array($voices) ? $voices : [];
120    }
121
122    /**
123     * Render the language select dropdown.
124     *
125     * @since 6.0.0
126     *
127     * @param array $languages The languages array.
128     * @param string|false $selectedLanguageCode The selected language code.
129     */
130    private static function renderLanguageSelect(array $languages, $selectedLanguageCode): void
131    {
132        ?>
133        <p
134            id="beyondwords-metabox-select-voice--language-code"
135            class="post-attributes-label-wrapper page-template-label-wrapper"
136        >
137            <label class="post-attributes-label" for="beyondwords_language_code">
138                Language
139            </label>
140        </p>
141        <select id="beyondwords_language_code" name="beyondwords_language_code" style="width: 100%;">
142            <?php
143            foreach ($languages as $language) {
144                if (empty($language['code']) || empty($language['name']) || empty($language['accent'])) {
145                    continue;
146                }
147                printf(
148                    '<option value="%s" data-default-voice-id="%s" %s>%s (%s)</option>',
149                    esc_attr($language['code']),
150                    esc_attr($language['default_voices']['body']['id'] ?? ''),
151                    selected(strval($language['code']), strval($selectedLanguageCode)),
152                    esc_html($language['name']),
153                    esc_html($language['accent'])
154                );
155            }
156            ?>
157        </select>
158        <?php
159    }
160
161    /**
162     * Render the voice select dropdown.
163     *
164     * @since 6.0.0
165     *
166     * @param array $voices The voices array.
167     * @param string|false $selectedVoiceId The selected voice ID.
168     * @param string|false $languageCode The language code.
169     */
170    private static function renderVoiceSelect(array $voices, $selectedVoiceId, $languageCode): void
171    {
172        ?>
173        <p
174            id="beyondwords-metabox-select-voice--voice-id"
175            class="post-attributes-label-wrapper page-template-label-wrapper"
176        >
177            <label class="post-attributes-label" for="beyondwords_voice_id">
178                Voice
179            </label>
180        </p>
181        <select
182            id="beyondwords_voice_id"
183            name="beyondwords_voice_id"
184            style="width: 100%;"
185            <?php echo disabled(!strval($languageCode)) ?>
186        >
187            <?php
188            foreach ($voices as $voice) {
189                printf(
190                    '<option value="%s" %s>%s</option>',
191                    esc_attr($voice['id']),
192                    selected(strval($voice['id']), strval($selectedVoiceId)),
193                    esc_html($voice['name'])
194                );
195            }
196            ?>
197        </select>
198        <?php
199    }
200
201    /**
202     * Render the loading spinner.
203     *
204     * @since 6.0.0
205     */
206    private static function renderLoadingSpinner(): void
207    {
208        ?>
209        <img
210            src="/wp-admin/images/spinner.gif"
211            class="beyondwords-settings__loader"
212            style="display:none; padding: 3px 0;"
213        />
214        <?php
215    }
216
217    /**
218     * Save the meta when the post is saved.
219     *
220     * @since 4.0.0
221     * @since 6.0.0 Make static.
222     *
223     * @param int $postId The ID of the post being saved.
224     */
225    public static function save($postId)
226    {
227        if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
228            return $postId;
229        }
230
231        // "save_post" can be triggered at other times, so verify this request came from the our component
232        if (
233            ! isset($_POST['beyondwords_language_code']) ||
234            ! isset($_POST['beyondwords_voice_id']) ||
235            ! isset($_POST['beyondwords_select_voice_nonce'])
236        ) {
237            return $postId;
238        }
239
240        // "save_post" can be triggered at other times, so verify this request came from the our component
241        if (
242            ! wp_verify_nonce(
243                sanitize_key($_POST['beyondwords_select_voice_nonce']),
244                'beyondwords_select_voice'
245            )
246        ) {
247            return $postId;
248        }
249
250        $languageCode = sanitize_text_field(wp_unslash($_POST['beyondwords_language_code']));
251
252        if (! empty($languageCode)) {
253            update_post_meta($postId, 'beyondwords_language_code', $languageCode);
254        } else {
255            delete_post_meta($postId, 'beyondwords_language_code');
256        }
257
258        $voiceId = sanitize_text_field(wp_unslash($_POST['beyondwords_voice_id']));
259
260        if (! empty($voiceId)) {
261            update_post_meta($postId, 'beyondwords_body_voice_id', $voiceId);
262            update_post_meta($postId, 'beyondwords_title_voice_id', $voiceId);
263            update_post_meta($postId, 'beyondwords_summary_voice_id', $voiceId);
264        } else {
265            delete_post_meta($postId, 'beyondwords_body_voice_id');
266            delete_post_meta($postId, 'beyondwords_title_voice_id');
267            delete_post_meta($postId, 'beyondwords_summary_voice_id');
268        }
269
270        return $postId;
271    }
272
273    /**
274     * Register WP REST API route
275     *
276     * @since 4.0.0
277     * @since 6.0.0 Make static.
278     *
279     * @return void
280     */
281    public static function restApiInit()
282    {
283        // Languages endpoint
284        register_rest_route('beyondwords/v1', '/languages', [
285            'methods'  => \WP_REST_Server::READABLE,
286            'callback' => [self::class, 'languagesRestApiResponse'],
287            'permission_callback' => fn() => current_user_can('edit_posts'),
288        ]);
289
290        // Voices endpoint
291        register_rest_route('beyondwords/v1', '/languages/(?P<languageCode>[a-zA-Z0-9-_]+)/voices', [
292            'methods'  => \WP_REST_Server::READABLE,
293            'callback' => [self::class, 'voicesRestApiResponse'],
294            'permission_callback' => fn() => current_user_can('edit_posts'),
295        ]);
296    }
297
298    /**
299     * "Languages" WP REST API response (required for the Gutenberg editor).
300     *
301     * @since 4.0.0
302     * @since 5.4.0 No longer filter by "Languages" plugin setting.
303     * @since 6.0.0 Make static.
304     *
305     * @return \WP_REST_Response
306     */
307    public static function languagesRestApiResponse()
308    {
309        $languages = ApiClient::getLanguages();
310
311        return new \WP_REST_Response($languages);
312    }
313
314    /**
315     * "Voices" WP REST API response (required for the Gutenberg editor
316     * and Block Editor).
317     *
318     * @since 4.0.0
319     * @since 6.0.0 Make static.
320     *
321     * @return \WP_REST_Response
322     */
323    public static function voicesRestApiResponse(\WP_REST_Request $data)
324    {
325        $params = $data->get_url_params();
326
327        $voices = ApiClient::getVoices($params['languageCode']);
328
329        return new \WP_REST_Response($voices);
330    }
331
332    /**
333     * Register the component scripts.
334     *
335     * @since 4.0.0
336     * @since 6.0.0 Make static.
337     *
338     * @param string $hook Page hook
339     *
340     * @return void
341     */
342    public static function adminEnqueueScripts($hook)
343    {
344        if (! CoreUtils::isGutenbergPage() && ( $hook === 'post.php' || $hook === 'post-new.php')) {
345            wp_register_script(
346                'beyondwords-metabox--select-voice',
347                BEYONDWORDS__PLUGIN_URI . 'src/Component/Post/SelectVoice/classic-metabox.js',
348                ['jquery', 'underscore'],
349                BEYONDWORDS__PLUGIN_VERSION,
350                true
351            );
352
353            /**
354             * Localize the script to handle ajax requests
355             */
356            wp_localize_script(
357                'beyondwords-metabox--select-voice',
358                'beyondwordsData',
359                [
360                    'nonce' => wp_create_nonce('wp_rest'),
361                    'root' => esc_url_raw(rest_url()),
362                ]
363            );
364
365            wp_enqueue_script('beyondwords-metabox--select-voice');
366        }
367    }
368}