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