Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
75.10% covered (warning)
75.10%
184 / 245
50.00% covered (danger)
50.00%
7 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Settings
75.10% covered (warning)
75.10%
184 / 245
50.00% covered (danger)
50.00%
7 / 14
53.91
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 addOptionsPage
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 maybeValidateApiCreds
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 createAdminInterface
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
4
 addSettingsLinkToPluginPage
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getTabs
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 getActiveTab
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
5.51
 printMissingApiCredsWarning
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
2
 maybePrintPluginReviewNotice
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
6
 printSettingsErrors
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
4
 restApiInit
90.48% covered (success)
90.48%
19 / 21
0.00% covered (danger)
0.00%
0 / 1
1.00
 restApiResponse
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 dismissReviewNotice
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 enqueueScripts
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3declare(strict_types=1);
4
5/**
6 * BeyondWords settings.
7 *
8 * @package Beyondwords\Wordpress
9 * @author  Stuart McAlpine <stu@beyondwords.io>
10 * @since   3.0.0
11 */
12
13namespace Beyondwords\Wordpress\Component\Settings;
14
15use Beyondwords\Wordpress\Component\Settings\Fields\Languages\Languages;
16use Beyondwords\Wordpress\Component\Settings\Fields\PreselectGenerateAudio\PreselectGenerateAudio;
17use Beyondwords\Wordpress\Component\Settings\Tabs\Content\Content;
18use Beyondwords\Wordpress\Component\Settings\Tabs\Credentials\Credentials;
19use Beyondwords\Wordpress\Component\Settings\Tabs\Player\Player;
20use Beyondwords\Wordpress\Component\Settings\Tabs\Pronunciations\Pronunciations;
21use Beyondwords\Wordpress\Component\Settings\Tabs\Summarization\Summarization;
22use Beyondwords\Wordpress\Component\Settings\Tabs\Voices\Voices;
23use Beyondwords\Wordpress\Component\Settings\SettingsUtils;
24use Beyondwords\Wordpress\Component\Settings\Sync;
25use Beyondwords\Wordpress\Core\Environment;
26
27/**
28 * Settings
29 *
30 * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
31 *
32 * @since 3.0.0
33 */
34class Settings
35{
36    public const REVIEW_NOTICE_TIME_FORMAT = '-14 days';
37
38    /**
39     * Init
40     *
41     * @since 3.0.0 Introduced.
42     * @since 5.4.0 Add plugin review notice.
43     */
44    public function init()
45    {
46        (new Credentials())->init();
47        (new Sync())->init();
48
49        if (SettingsUtils::hasValidApiConnection()) {
50            (new Voices())->init();
51            (new Content())->init();
52            (new Player())->init();
53            (new Summarization())->init();
54            (new Pronunciations())->init();
55        }
56
57        add_action('admin_menu', array($this, 'addOptionsPage'), 1);
58        add_action('admin_notices', array($this, 'printMissingApiCredsWarning'), 100);
59        add_action('admin_notices', array($this, 'printSettingsErrors'), 200);
60        add_action('admin_notices', array($this, 'maybePrintPluginReviewNotice'));
61        add_action('admin_enqueue_scripts', array($this, 'enqueueScripts'));
62        add_action('load-settings_page_beyondwords', array($this, 'maybeValidateApiCreds'));
63
64        add_action('rest_api_init', array($this, 'restApiInit'));
65
66        add_filter('plugin_action_links_speechkit/speechkit.php', array($this, 'addSettingsLinkToPluginPage'));
67    }
68
69    /**
70     * Add items to the WordPress admin menu.
71     *
72     * @since  3.0.0
73     *
74     * @return void
75     */
76    public function addOptionsPage()
77    {
78        // Settings > BeyondWords
79        add_options_page(
80            __('BeyondWords Settings', 'speechkit'),
81            __('BeyondWords', 'speechkit'),
82            'manage_options',
83            'beyondwords',
84            array($this, 'createAdminInterface')
85        );
86    }
87
88    /**
89     * Validate API creds if we are on the credentials tab.
90     *
91     * @since 5.4.0
92     *
93     * @return void
94     */
95    public function maybeValidateApiCreds()
96    {
97        $activeTab = self::getActiveTab();
98
99        if ($activeTab === 'credentials') {
100            SettingsUtils::validateApiConnection();
101        }
102    }
103
104    /**
105     * Prints the admin interface for plugin settings.
106     *
107     * @since 3.0.0
108     * @since 4.7.0 Added tabs.
109     *
110     * @return void
111     */
112    public function createAdminInterface()
113    {
114        $tabs      = self::getTabs();
115        $activeTab = self::getActiveTab();
116        ?>
117        <div class="wrap">
118            <h1>
119                <?php esc_attr_e('BeyondWords Settings', 'speechkit'); ?>
120            </h1>
121
122            <form
123                id="beyondwords-plugin-settings"
124                action="<?php echo esc_url(admin_url('options.php')); ?>"
125                method="post"
126            >
127                <nav class="nav-tab-wrapper">
128                    <ul>
129                        <?php
130                        foreach ($tabs as $id => $title) {
131                            $activeClass = $id === $activeTab ? 'nav-tab-active' : '';
132
133                            $url = add_query_arg([
134                                'page' => 'beyondwords',
135                                'tab'  => urlencode($id),
136                            ]);
137                            ?>
138                            <li>
139                                <a
140                                    class="nav-tab <?php echo esc_attr($activeClass); ?>"
141                                    href="<?php echo esc_url($url); ?>"
142                                >
143                                    <?php echo wp_kses_post($title); ?>
144                                </a>
145                            </li>
146                            <?php
147                        }
148                        ?>
149                    </ul>
150                </nav>
151
152                <hr class="wp-header-end">
153
154                <?php
155                settings_fields("beyondwords_{$activeTab}_settings");
156                do_settings_sections("beyondwords_{$activeTab}");
157
158                // Some tabs have no fields to submit
159                if (! in_array($activeTab, ['summarization', 'pronunciations'])) {
160                    submit_button('Save changes');
161                }
162                ?>
163            </form>
164        </div>
165        <?php
166    }
167
168    /**
169     * Add "Settings" link to plugin page.
170     *
171     * @since 3.0.0
172     * @since 4.7.0 Prepend custom links instead of appending them.
173     */
174    public function addSettingsLinkToPluginPage($links)
175    {
176        $settingsLink = '<a href="' .
177            esc_url(admin_url('options-general.php?page=beyondwords')) .
178            '">' . __('Settings', 'speechkit') . '</a>';
179
180        array_unshift($links, $settingsLink);
181
182        return $links;
183    }
184
185    /**
186     * Get tabs.
187     *
188     * @since 4.7.0
189     * @since 5.2.0 Make static.
190     *
191     * @return array Tabs
192     */
193    public static function getTabs()
194    {
195        $tabs = array(
196            'credentials'    => __('Credentials', 'speechkit'),
197            'content'        => __('Content', 'speechkit'),
198            'voices'         => __('Voices', 'speechkit'),
199            'player'         => __('Player', 'speechkit'),
200            'summarization'  => __('Summarization', 'speechkit'),
201            'pronunciations' => __('Pronunciations', 'speechkit'),
202        );
203
204        if (! SettingsUtils::hasValidApiConnection()) {
205            $tabs = array_splice($tabs, 0, 1);
206        }
207
208        return $tabs;
209    }
210
211    /**
212     * Get active tab.
213     *
214     * @since 4.7.0
215     * @since 5.2.0 Make static.
216     *
217     * @return string Active tab
218     */
219    public static function getActiveTab()
220    {
221        $tabs = self::getTabs();
222
223        if (! count($tabs)) {
224            return '';
225        }
226
227        $defaultTab = array_key_first($tabs);
228
229        // phpcs:disable WordPress.Security.NonceVerification.Recommended
230        if (isset($_GET['tab'])) {
231            $tab = sanitize_text_field(wp_unslash($_GET['tab']));
232        } else {
233            $tab = $defaultTab;
234        }
235        // phpcs:enable WordPress.Security.NonceVerification.Recommended
236
237        if (!empty($tab) && array_key_exists($tab, $tabs)) {
238            $activeTab = $tab;
239        } else {
240            $activeTab = $defaultTab;
241        }
242
243        return $activeTab;
244    }
245
246    /**
247     * Print missing API creds warning.
248     *
249     * @since 5.2.0
250     *
251     * @return void
252     */
253    public function printMissingApiCredsWarning()
254    {
255        if (! SettingsUtils::hasApiCreds()) :
256            ?>
257            <div class="notice notice-info">
258                <p>
259                    <strong>
260                        <?php
261                        printf(
262                            /* translators: %s is replaced with a "plugin settings" link */
263                            esc_html__('To use BeyondWords, please update the %s.', 'speechkit'),
264                            sprintf(
265                                '<a href="%s">%s</a>',
266                                esc_url(admin_url('options-general.php?page=beyondwords')),
267                                esc_html__('plugin settings', 'speechkit')
268                            )
269                        );
270                        ?>
271                    </strong>
272                </p>
273                <p>
274                    <?php esc_html_e('Don’t have a BeyondWords account yet?', 'speechkit'); ?>
275                </p>
276                <p>
277                    <a
278                        class="button button-secondary"
279                        href="<?php echo esc_url(sprintf('%s/auth/signup', Environment::getDashboardUrl())); ?>"
280                        target="_blank"
281                    >
282                        <?php esc_html_e('Sign up free', 'speechkit'); ?>
283                    </a>
284                </p>
285            </div>
286            <?php
287        endif;
288    }
289
290    /**
291     * Maybe print plugin review notice.
292     *
293     * @since 5.4.0
294     *
295     * @return void
296     */
297    public function maybePrintPluginReviewNotice()
298    {
299        $screen = get_current_screen();
300        if ($screen && 'settings_page_beyondwords' !== $screen->id) {
301            return;
302        }
303
304        $dateActivated       = get_option('beyondwords_date_activated', '2025-03-01');
305        $dateNoticeDismissed = get_option('beyondwords_notice_review_dismissed', '');
306
307        $showNotice = false;
308
309        if (empty($dateNoticeDismissed)) {
310            $dateActivated = strtotime($dateActivated);
311
312            if ($dateActivated < strtotime(self::REVIEW_NOTICE_TIME_FORMAT)) {
313                $showNotice = true;
314            }
315        }
316
317        if ($showNotice) :
318            ?>
319            <div id="beyondwords_notice_review" class="notice notice-info is-dismissible">
320                <p>
321                    <strong>
322                        <?php
323                        printf(
324                            /* translators: %s is replaced with a "WordPress Plugin Repo" link */
325                            esc_html__('Happy with our work? Help us spread the word with a rating on the %s.', 'speechkit'), // phpcs:ignore Generic.Files.LineLength.TooLong
326                            sprintf(
327                                '<a href="%s">%s</a>',
328                                'https://wordpress.org/support/plugin/speechkit/reviews/',
329                                esc_html__('WordPress Plugin Repo', 'speechkit')
330                            )
331                        );
332                        ?>
333                    </strong>
334                </p>
335            </div>
336            <?php
337        endif;
338    }
339
340    /**
341     * Print settings errors.
342     *
343     * @since 3.0.0
344     *
345     * @return void
346     */
347    public function printSettingsErrors()
348    {
349        $settingsErrors = wp_cache_get('beyondwords_settings_errors', 'beyondwords');
350        wp_cache_delete('beyondwords_settings_errors', 'beyondwords');
351
352        if (is_array($settingsErrors) && count($settingsErrors)) :
353            ?>
354            <div class="notice notice-error">
355                <ul class="ul-disc">
356                    <?php
357                    foreach ($settingsErrors as $error) {
358                        printf(
359                            '<li>%s</li>',
360                            // Only allow links with href and target attributes
361                            wp_kses(
362                                $error,
363                                array(
364                                    'a' => array(
365                                        'href'   => array(),
366                                        'target' => array(),
367                                    ),
368                                    'b' => array(),
369                                    'strong' => array(),
370                                    'i' => array(),
371                                    'em' => array(),
372                                    'br' => array(),
373                                    'code' => array(),
374                                )
375                            )
376                        );
377                    }
378                    ?>
379                </ul>
380            </div>
381            <?php
382        endif;
383    }
384
385    /**
386     * Register WP REST API routes
387     *
388     * @since 5.4.0 Add REST API route to dismiss review notice.
389     *
390     * @return void
391     */
392    public function restApiInit()
393    {
394        // settings endpoint
395        register_rest_route('beyondwords/v1', '/settings', array(
396            'methods'  => \WP_REST_Server::READABLE,
397            'callback' => array($this, 'restApiResponse'),
398            'permission_callback' => function () {
399                return current_user_can('edit_posts');
400            },
401        ));
402
403        // settings endpoint
404        register_rest_route('beyondwords/v1', '/settings', array(
405            'methods'  => \WP_REST_Server::READABLE,
406            'callback' => array($this, 'restApiResponse'),
407            'permission_callback' => function () {
408                return current_user_can('edit_posts');
409            },
410        ));
411
412        // dismiss review notice endpoint
413        register_rest_route('beyondwords/v1', '/settings/notices/review/dismiss', array(
414            'methods'  => \WP_REST_Server::CREATABLE,
415            'callback' => array($this, 'dismissReviewNotice'),
416            'permission_callback' => function () {
417                return current_user_can('manage_options');
418            },
419        ));
420    }
421
422    /**
423     * WP REST API response (required for the Gutenberg editor).
424     *
425     * DO NOT expose ALL settings e.g. be sure to never expose the API key.
426     *
427     * @since 3.0.0
428     * @since 3.4.0 Add pluginVersion and wpVersion.
429     *
430     * @return \WP_REST_Response
431     */
432    public function restApiResponse()
433    {
434        global $wp_version;
435
436        return new \WP_REST_Response([
437            'apiKey'              => get_option('beyondwords_api_key', ''),
438            'pluginVersion'       => BEYONDWORDS__PLUGIN_VERSION,
439            'projectId'           => get_option('beyondwords_project_id', ''),
440            'preselect'           => get_option('beyondwords_preselect', PreselectGenerateAudio::DEFAULT_PRESELECT),
441            'projectLanguageCode' => get_option('beyondwords_project_language_code', ''),
442            'projectBodyVoiceId'  => get_option('beyondwords_project_body_voice_id', ''),
443            'wpVersion'           => $wp_version,
444        ]);
445    }
446
447    /**
448     * Dismiss review notice.
449     *
450     * @since 5.4.0
451     *
452     * @return \WP_REST_Response
453     */
454    public function dismissReviewNotice()
455    {
456        $success = update_option('beyondwords_notice_review_dismissed', gmdate(\DateTime::ATOM));
457
458        return new \WP_REST_Response(
459            [
460                'success' => $success
461            ],
462            $success ? 200 : 500
463        );
464    }
465
466    /**
467     * Register the settings script.
468     *
469     * @since 5.0.0
470     *
471     * @param string $hook Page hook
472     *
473     * @return void
474     */
475    public function enqueueScripts($hook)
476    {
477        if ($hook === 'settings_page_beyondwords') {
478            // jQuery UI JS
479            wp_enqueue_script('jquery-ui-core');// enqueue jQuery UI Core
480            wp_enqueue_script('jquery-ui-tabs');// enqueue jQuery UI Tabs
481
482            // Plugin settings JS
483            wp_register_script(
484                'beyondwords-settings',
485                BEYONDWORDS__PLUGIN_URI . 'build/settings.js',
486                ['jquery', 'jquery-ui-core', 'jquery-ui-tabs', 'underscore', 'tom-select'],
487                BEYONDWORDS__PLUGIN_VERSION,
488                true
489            );
490
491            // Tom Select JS
492            wp_enqueue_script(
493                'tom-select',
494                'https://cdn.jsdelivr.net/npm/tom-select@2.2.2/dist/js/tom-select.complete.min.js', // phpcs:ignore
495                [],
496                '2.2.2',
497                true
498            );
499
500            // Plugin settings CSS
501            wp_enqueue_style(
502                'beyondwords-settings',
503                BEYONDWORDS__PLUGIN_URI . 'src/Component/Settings/settings.css',
504                'forms',
505                BEYONDWORDS__PLUGIN_VERSION
506            );
507
508            // Tom Select CSS
509            wp_enqueue_style(
510                'tom-select',
511                'https://cdn.jsdelivr.net/npm/tom-select@2.2.2/dist/css/tom-select.css', // phpcs:ignore
512                false,
513                BEYONDWORDS__PLUGIN_VERSION
514            );
515
516            /**
517             * Localize the script to handle ajax requests
518             */
519            wp_add_inline_script(
520                'beyondwords-settings',
521                '
522                var beyondwordsData = beyondwordsData || {};
523                beyondwordsData.nonce = "' . wp_create_nonce('wp_rest') . '";
524                beyondwordsData.root = "' . esc_url_raw(rest_url()) . '";
525                ',
526                'before',
527            );
528
529            wp_enqueue_script('beyondwords-settings');
530        }
531    }
532}