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