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 */
35class Settings
36{
37    public const REVIEW_NOTICE_TIME_FORMAT = '-14 days';
38
39    /**
40     * Init
41     *
42     * @since 3.0.0 Introduced.
43     * @since 5.4.0 Add plugin review notice.
44     * @since 6.0.0 Make static.
45     */
46    public static function init()
47    {
48        (new Credentials())::init();
49        (new Sync())::init();
50
51        if (SettingsUtils::hasValidApiConnection()) {
52            (new Voices())::init();
53            (new Content())::init();
54            (new Player())::init();
55            (new Summarization())::init();
56            (new Pronunciations())::init();
57        }
58
59        add_action('admin_menu', [self::class, 'addOptionsPage'], 1);
60        add_action('admin_notices', [self::class, 'printMissingApiCredsWarning'], 100);
61        add_action('admin_notices', [self::class, 'printSettingsErrors'], 200);
62        add_action('admin_notices', [self::class, 'maybePrintPluginReviewNotice']);
63        add_action('admin_enqueue_scripts', [self::class, 'enqueueScripts']);
64        add_action('load-settings_page_beyondwords', [self::class, 'maybeValidateApiCreds']);
65
66        add_action('rest_api_init', [self::class, 'restApiInit']);
67
68        add_filter('plugin_action_links_speechkit/speechkit.php', [self::class, 'addSettingsLinkToPluginPage']);
69    }
70
71    /**
72     * Add items to the WordPress admin menu.
73     *
74     * @since  3.0.0
75     * @since 6.0.0 Make static.
76     */
77    public static function addOptionsPage(): void
78    {
79        // Settings > BeyondWords
80        add_options_page(
81            __('BeyondWords Settings', 'speechkit'),
82            __('BeyondWords', 'speechkit'),
83            'manage_options',
84            'beyondwords',
85            [self::class, 'createAdminInterface']
86        );
87    }
88
89    /**
90     * Validate API creds if we are on the credentials tab.
91     *
92     * @since 5.4.0
93     * @since 6.0.0 Make static.
94     */
95    public static function maybeValidateApiCreds(): void
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     * @since 6.0.0 Make static.
110     */
111    public static function createAdminInterface(): void
112    {
113        $tabs      = self::getTabs();
114        $activeTab = self::getActiveTab();
115        ?>
116        <div class="wrap">
117            <h1>
118                <?php esc_attr_e('BeyondWords Settings', 'speechkit'); ?>
119            </h1>
120
121            <form
122                id="beyondwords-plugin-settings"
123                action="<?php echo esc_url(admin_url('options.php')); ?>"
124                method="post"
125            >
126                <nav class="nav-tab-wrapper">
127                    <ul>
128                        <?php
129                        foreach ($tabs as $id => $title) {
130                            $activeClass = $id === $activeTab ? 'nav-tab-active' : '';
131
132                            $url = add_query_arg([
133                                'page' => 'beyondwords',
134                                'tab'  => urlencode($id),
135                            ]);
136                            ?>
137                            <li>
138                                <a
139                                    class="nav-tab <?php echo esc_attr($activeClass); ?>"
140                                    href="<?php echo esc_url($url); ?>"
141                                >
142                                    <?php echo wp_kses_post($title); ?>
143                                </a>
144                            </li>
145                            <?php
146                        }
147                        ?>
148                    </ul>
149                </nav>
150
151                <hr class="wp-header-end">
152
153                <?php
154                settings_fields("beyondwords_{$activeTab}_settings");
155                do_settings_sections("beyondwords_{$activeTab}");
156
157                // Some tabs have no fields to submit
158                if (! in_array($activeTab, ['summarization', 'pronunciations'])) {
159                    submit_button('Save changes');
160                }
161                ?>
162            </form>
163        </div>
164        <?php
165    }
166
167    /**
168     * Add "Settings" link to plugin page.
169     *
170     * @since 3.0.0
171     * @since 4.7.0 Prepend custom links instead of appending them.
172     * @since 6.0.0 Make static.
173     */
174    public static function addSettingsLinkToPluginPage(array $links): array
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(): array
194    {
195        $tabs = [
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(): string
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     * @since 6.0.0 Make static.
251     */
252    public static function printMissingApiCredsWarning(): void
253    {
254        if (! SettingsUtils::hasApiCreds()) :
255            ?>
256            <div class="notice notice-info">
257                <p>
258                    <strong>
259                        <?php
260                        printf(
261                            /* translators: %s is replaced with a "plugin settings" link */
262                            esc_html__('To use BeyondWords, please update the %s.', 'speechkit'),
263                            sprintf(
264                                '<a href="%s">%s</a>',
265                                esc_url(admin_url('options-general.php?page=beyondwords')),
266                                esc_html__('plugin settings', 'speechkit')
267                            )
268                        );
269                        ?>
270                    </strong>
271                </p>
272                <p>
273                    <?php esc_html_e('Don’t have a BeyondWords account yet?', 'speechkit'); ?>
274                </p>
275                <p>
276                    <a
277                        class="button button-secondary"
278                        href="<?php echo esc_url(sprintf('%s/auth/signup', Environment::getDashboardUrl())); ?>"
279                        target="_blank"
280                    >
281                        <?php esc_html_e('Sign up free', 'speechkit'); ?>
282                    </a>
283                </p>
284            </div>
285            <?php
286        endif;
287    }
288
289    /**
290     * Maybe print plugin review notice.
291     *
292     * @since 5.4.0
293     * @since 6.0.0 Make static.
294     */
295    public static function maybePrintPluginReviewNotice(): void
296    {
297        $screen = get_current_screen();
298        if ($screen && 'settings_page_beyondwords' !== $screen->id) {
299            return;
300        }
301
302        $dateActivated       = get_option('beyondwords_date_activated', '2025-03-01');
303        $dateNoticeDismissed = get_option('beyondwords_notice_review_dismissed', '');
304
305        $showNotice = false;
306
307        if (empty($dateNoticeDismissed)) {
308            $dateActivated = strtotime($dateActivated);
309
310            if ($dateActivated < strtotime(self::REVIEW_NOTICE_TIME_FORMAT)) {
311                $showNotice = true;
312            }
313        }
314
315        if ($showNotice) :
316            ?>
317            <div id="beyondwords_notice_review" class="notice notice-info is-dismissible">
318                <p>
319                    <strong>
320                        <?php
321                        printf(
322                            /* translators: %s is replaced with a "WordPress Plugin Repo" link */
323                            esc_html__('Happy with our work? Help us spread the word with a rating on the %s.', 'speechkit'), // phpcs:ignore Generic.Files.LineLength.TooLong
324                            sprintf(
325                                '<a href="%s">%s</a>',
326                                'https://wordpress.org/support/plugin/speechkit/reviews/',
327                                esc_html__('WordPress Plugin Repo', 'speechkit')
328                            )
329                        );
330                        ?>
331                    </strong>
332                </p>
333            </div>
334            <?php
335        endif;
336    }
337
338    /**
339     * Print settings errors.
340     *
341     * @since 3.0.0
342     * @since 6.0.0 Make static.
343     */
344    public static function printSettingsErrors(): void
345    {
346        $settingsErrors = wp_cache_get('beyondwords_settings_errors', 'beyondwords');
347        wp_cache_delete('beyondwords_settings_errors', 'beyondwords');
348
349        if (is_array($settingsErrors) && count($settingsErrors)) :
350            ?>
351            <div class="notice notice-error">
352                <ul class="ul-disc">
353                    <?php
354                    foreach ($settingsErrors as $error) {
355                        printf(
356                            '<li>%s</li>',
357                            // Only allow links with href and target attributes
358                            wp_kses(
359                                $error,
360                                [
361                                    'a' => [
362                                        'href'   => [],
363                                        'target' => [],
364                                    ],
365                                    'b' => [],
366                                    'strong' => [],
367                                    'i' => [],
368                                    'em' => [],
369                                    'br' => [],
370                                    'code' => [],
371                                ]
372                            )
373                        );
374                    }
375                    ?>
376                </ul>
377            </div>
378            <?php
379        endif;
380    }
381
382    /**
383     * Register WP REST API routes
384     *
385     * @since 5.4.0 Add REST API route to dismiss review notice.
386     * @since 6.0.0 Make static.
387     */
388    public static function restApiInit(): void
389    {
390        // settings endpoint
391        register_rest_route('beyondwords/v1', '/settings', [
392            'methods'  => \WP_REST_Server::READABLE,
393            'callback' => [self::class, 'restApiResponse'],
394            'permission_callback' => fn() => current_user_can('edit_posts'),
395        ]);
396
397        // settings endpoint
398        register_rest_route('beyondwords/v1', '/settings', [
399            'methods'  => \WP_REST_Server::READABLE,
400            'callback' => [self::class, 'restApiResponse'],
401            'permission_callback' => fn() => current_user_can('edit_posts'),
402        ]);
403
404        // dismiss review notice endpoint
405        register_rest_route('beyondwords/v1', '/settings/notices/review/dismiss', [
406            'methods'  => \WP_REST_Server::CREATABLE,
407            'callback' => [self::class, 'dismissReviewNotice'],
408            'permission_callback' => fn() => current_user_can('manage_options'),
409        ]);
410    }
411
412    /**
413     * WP REST API response (required for the Gutenberg editor).
414     *
415     * DO NOT expose ALL settings e.g. be sure to never expose the API key.
416     *
417     * @since 3.0.0
418     * @since 3.4.0 Add pluginVersion and wpVersion.
419     * @since 6.0.0 Make static and add integrationMethod.
420     *
421     * @return \WP_REST_Response
422     */
423    public static function restApiResponse()
424    {
425        global $wp_version;
426
427        return new \WP_REST_Response([
428            'apiKey'              => get_option('beyondwords_api_key', ''),
429            'integrationMethod'   => IntegrationMethod::getIntegrationMethod(),
430            'pluginVersion'       => BEYONDWORDS__PLUGIN_VERSION,
431            'projectId'           => get_option('beyondwords_project_id', ''),
432            'preselect'           => get_option(PreselectGenerateAudio::OPTION_NAME, PreselectGenerateAudio::DEFAULT_PRESELECT), // phpcs:ignore Generic.Files.LineLength.TooLong
433            'projectLanguageCode' => get_option('beyondwords_project_language_code', ''),
434            'projectBodyVoiceId'  => get_option('beyondwords_project_body_voice_id', ''),
435            'restUrl'             => get_rest_url(),
436            'wpVersion'           => $wp_version,
437        ]);
438    }
439
440    /**
441     * Dismiss review notice.
442     *
443     * @since 5.4.0
444     * @since 6.0.0 Make static.
445     *
446     * @return \WP_REST_Response
447     */
448    public static function dismissReviewNotice()
449    {
450        $success = update_option('beyondwords_notice_review_dismissed', gmdate(\DateTime::ATOM));
451
452        return new \WP_REST_Response(
453            [
454                'success' => $success
455            ],
456            $success ? 200 : 500
457        );
458    }
459
460    /**
461     * Register the settings script.
462     *
463     * @since 5.0.0
464     * @since 6.0.0 Make static.
465     *
466     * @param string $hook Page hook
467     */
468    public static function enqueueScripts(string $hook): void
469    {
470        if ($hook === 'settings_page_beyondwords') {
471            // jQuery UI JS
472            wp_enqueue_script('jquery-ui-core');// enqueue jQuery UI Core
473            wp_enqueue_script('jquery-ui-tabs');// enqueue jQuery UI Tabs
474
475            // Plugin settings JS
476            wp_register_script(
477                'beyondwords-settings',
478                BEYONDWORDS__PLUGIN_URI . 'build/settings.js',
479                ['jquery', 'jquery-ui-core', 'jquery-ui-tabs', 'underscore', 'tom-select'],
480                BEYONDWORDS__PLUGIN_VERSION,
481                true
482            );
483
484            // Tom Select JS
485            wp_enqueue_script(
486                'tom-select',
487                'https://cdn.jsdelivr.net/npm/tom-select@2.2.2/dist/js/tom-select.complete.min.js', // phpcs:ignore
488                [],
489                '2.2.2',
490                true
491            );
492
493            // Plugin settings CSS
494            wp_enqueue_style(
495                'beyondwords-settings',
496                BEYONDWORDS__PLUGIN_URI . 'src/Component/Settings/settings.css',
497                'forms',
498                BEYONDWORDS__PLUGIN_VERSION
499            );
500
501            // Tom Select CSS
502            wp_enqueue_style(
503                'tom-select',
504                'https://cdn.jsdelivr.net/npm/tom-select@2.2.2/dist/css/tom-select.css', // phpcs:ignore
505                false,
506                BEYONDWORDS__PLUGIN_VERSION
507            );
508
509            /**
510             * Localize the script to handle ajax requests
511             */
512            wp_add_inline_script(
513                'beyondwords-settings',
514                '
515                var beyondwordsData = beyondwordsData || {};
516                beyondwordsData.nonce = "' . wp_create_nonce('wp_rest') . '";
517                beyondwordsData.root = "' . esc_url_raw(rest_url()) . '";
518                ',
519                'before',
520            );
521
522            wp_enqueue_script('beyondwords-settings');
523        }
524    }
525}