Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.09% covered (success)
99.09%
217 / 219
93.75% covered (success)
93.75%
15 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
Settings
99.09% covered (success)
99.09%
217 / 219
93.75% covered (success)
93.75%
15 / 16
33
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 add_options_page
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 maybe_validate_api_creds
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 render_admin_page
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
3
 add_plugin_action_link
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 maybe_print_missing_creds_warning
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
2
 maybe_print_review_notice
92.00% covered (success)
92.00%
23 / 25
0.00% covered (danger)
0.00%
0 / 1
7.03
 print_settings_errors
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
7
 register_rest_routes
100.00% covered (success)
100.00%
54 / 54
100.00% covered (success)
100.00%
1 / 1
1
 rest_settings_response
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 inspect_meta_keys
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
1
 rest_dismiss_review_notice
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 rest_video_settings_response
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 rest_project_response
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 rest_summarization_settings_templates_response
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 rest_video_settings_templates_response
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * BeyondWords settings page: admin menu, tabbed form, REST endpoints, notices.
4 *
5 * @package BeyondWords\Settings
6 *
7 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
8 */
9
10declare( strict_types = 1 );
11
12namespace BeyondWords\Settings;
13
14defined( 'ABSPATH' ) || exit;
15
16/**
17 * Settings page.
18 *
19 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
20 */
21class Settings {
22
23    const PAGE_SLUG            = 'beyondwords';
24    const REVIEW_NOTICE_OFFSET = '-14 days';
25
26    /**
27     * Register WordPress hooks.
28     */
29    public static function init(): void {
30        add_action( 'admin_menu', [ self::class, 'add_options_page' ], 1 );
31        add_action( 'admin_notices', [ self::class, 'maybe_print_missing_creds_warning' ], 100 );
32        add_action( 'admin_notices', [ self::class, 'print_settings_errors' ], 200 );
33        add_action( 'admin_notices', [ self::class, 'maybe_print_review_notice' ] );
34        add_action( 'load-settings_page_' . self::PAGE_SLUG, [ self::class, 'maybe_validate_api_creds' ] );
35
36        add_action( 'rest_api_init', [ self::class, 'register_rest_routes' ] );
37
38        add_filter( 'plugin_action_links_speechkit/speechkit.php', [ self::class, 'add_plugin_action_link' ] );
39    }
40
41    /**
42     * Register the BeyondWords settings page under Settings.
43     */
44    public static function add_options_page(): void {
45        add_options_page(
46            __( 'BeyondWords Settings', 'speechkit' ),
47            __( 'BeyondWords', 'speechkit' ),
48            'manage_options',
49            self::PAGE_SLUG,
50            [ self::class, 'render_admin_page' ]
51        );
52    }
53
54    /**
55     * Validate API credentials whenever the Authentication tab loads.
56     *
57     * Triggered on the page-specific load hook so we don't pay the API cost
58     * on unrelated admin screens.
59     */
60    public static function maybe_validate_api_creds(): void {
61        if ( Tabs::TAB_AUTHENTICATION === Tabs::get_active_tab() ) {
62            Utils::validate_api_connection();
63        }
64    }
65
66    /**
67     * Render the settings page (the tabbed form).
68     */
69    public static function render_admin_page(): void {
70        $tabs       = Tabs::get_visible_tabs();
71        $active_tab = Tabs::get_active_tab();
72        $active     = Tabs::get_active_page_and_group();
73        ?>
74        <div class="wrap">
75            <h1><?php esc_html_e( 'BeyondWords Settings', 'speechkit' ); ?></h1>
76
77            <form
78                id="beyondwords-plugin-settings"
79                action="<?php echo esc_url( admin_url( 'options.php' ) ); ?>"
80                method="post"
81            >
82                <nav class="nav-tab-wrapper">
83                    <ul>
84                        <?php foreach ( $tabs as $slug => $label ) : ?>
85                            <li>
86                                <a
87                                    class="nav-tab <?php echo $slug === $active_tab ? 'nav-tab-active' : ''; ?>"
88                                    href="<?php echo esc_url( add_query_arg( [ 'page' => self::PAGE_SLUG, 'tab' => $slug ] ) ); ?>"
89                                >
90                                    <?php echo esc_html( $label ); ?>
91                                </a>
92                            </li>
93                        <?php endforeach; ?>
94                    </ul>
95                </nav>
96
97                <hr class="wp-header-end">
98
99                <?php
100                settings_fields( $active['group'] );
101                do_settings_sections( $active['page'] );
102                submit_button( __( 'Save changes', 'speechkit' ) );
103                ?>
104            </form>
105        </div>
106        <?php
107    }
108
109    /**
110     * Add a "Settings" link to the plugin row.
111     *
112     * @param string[] $links Existing action links.
113     * @return string[]
114     */
115    public static function add_plugin_action_link( array $links ): array {
116        $settings_link = sprintf(
117            '<a href="%s">%s</a>',
118            esc_url( admin_url( 'options-general.php?page=' . self::PAGE_SLUG ) ),
119            esc_html__( 'Settings', 'speechkit' )
120        );
121
122        array_unshift( $links, $settings_link );
123        return $links;
124    }
125
126    /**
127     * Show a banner pointing to the settings page until creds are entered.
128     */
129    public static function maybe_print_missing_creds_warning(): void {
130        if ( Utils::has_api_creds() ) {
131            return;
132        }
133        ?>
134        <div class="notice notice-info">
135            <p>
136                <strong>
137                    <?php
138                    printf(
139                        /* translators: %s is the "plugin settings" link */
140                        esc_html__( 'To use BeyondWords, please update the %s.', 'speechkit' ),
141                        sprintf(
142                            '<a href="%s">%s</a>',
143                            esc_url( admin_url( 'options-general.php?page=' . self::PAGE_SLUG ) ),
144                            esc_html__( 'plugin settings', 'speechkit' )
145                        )
146                    );
147                    ?>
148                </strong>
149            </p>
150            <p><?php esc_html_e( 'Don’t have a BeyondWords account yet?', 'speechkit' ); ?></p>
151            <p>
152                <a
153                    class="button button-secondary"
154                    href="<?php echo esc_url( sprintf( '%s/auth/signup', \BeyondWords\Core\Urls::get_dashboard_url() ) ); ?>"
155                    target="_blank"
156                >
157                    <?php esc_html_e( 'Sign up free', 'speechkit' ); ?>
158                </a>
159            </p>
160        </div>
161        <?php
162    }
163
164    /**
165     * Show the once-only review notice 14+ days after activation.
166     */
167    public static function maybe_print_review_notice(): void {
168        $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
169        if ( ! $screen || 'settings_page_' . self::PAGE_SLUG !== $screen->id ) {
170            return;
171        }
172
173        $dismissed = get_option( 'beyondwords_notice_review_dismissed', '' );
174        if ( ! empty( $dismissed ) ) {
175            return;
176        }
177
178        $activated_at = strtotime( (string) get_option( 'beyondwords_date_activated', '2025-03-01' ) );
179        if ( false === $activated_at || $activated_at >= strtotime( self::REVIEW_NOTICE_OFFSET ) ) {
180            return;
181        }
182        ?>
183        <div id="beyondwords_notice_review" class="notice notice-info is-dismissible">
184            <p>
185                <strong>
186                    <?php
187                    printf(
188                        /* translators: %s is the link to the WordPress plugin repo */
189                        esc_html__( 'Happy with our work? Help us spread the word with a rating on the %s.', 'speechkit' ),
190                        sprintf(
191                            '<a href="%s">%s</a>',
192                            'https://wordpress.org/support/plugin/speechkit/reviews/',
193                            esc_html__( 'WordPress Plugin Repo', 'speechkit' )
194                        )
195                    );
196                    ?>
197                </strong>
198            </p>
199        </div>
200        <?php
201    }
202
203    /**
204     * Drain queued settings errors into a notice.
205     *
206     * `admin_notices` fires on every admin screen, so this early-returns off the
207     * settings page — the only screen the errors are ever queued for.
208     */
209    public static function print_settings_errors(): void {
210        $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
211        if ( ! $screen || 'settings_page_' . self::PAGE_SLUG !== $screen->id ) {
212            return;
213        }
214
215        $errors = get_transient( 'beyondwords_settings_errors' );
216
217        if ( ! is_array( $errors ) || empty( $errors ) ) {
218            return;
219        }
220
221        delete_transient( 'beyondwords_settings_errors' );
222
223        $allowed = [
224            'a'      => [ 'href' => [], 'target' => [] ],
225            'b'      => [],
226            'strong' => [],
227            'i'      => [],
228            'em'     => [],
229            'br'     => [],
230            'code'   => [],
231        ];
232        ?>
233        <div class="notice notice-error">
234            <ul class="ul-disc">
235                <?php foreach ( $errors as $error ) : ?>
236                    <li><?php echo wp_kses( $error, $allowed ); ?></li>
237                <?php endforeach; ?>
238            </ul>
239        </div>
240        <?php
241    }
242
243    /**
244     * Register REST routes consumed by the editor scripts.
245     */
246    public static function register_rest_routes(): void {
247        register_rest_route(
248            'beyondwords/v1',
249            '/settings',
250            [
251                'methods'             => \WP_REST_Server::READABLE,
252                'callback'            => [ self::class, 'rest_settings_response' ],
253                'permission_callback' => static fn() => current_user_can( 'edit_posts' ),
254            ]
255        );
256
257        register_rest_route(
258            'beyondwords/v1',
259            '/settings/notices/review/dismiss',
260            [
261                'methods'             => \WP_REST_Server::CREATABLE,
262                'callback'            => [ self::class, 'rest_dismiss_review_notice' ],
263                'permission_callback' => static fn() => current_user_can( 'manage_options' ),
264            ]
265        );
266
267        register_rest_route(
268            'beyondwords/v1',
269            '/projects/(?P<projectId>[0-9]+)/video-settings',
270            [
271                'methods'             => \WP_REST_Server::READABLE,
272                'callback'            => [ self::class, 'rest_video_settings_response' ],
273                'permission_callback' => static fn() => current_user_can( 'edit_posts' ),
274            ]
275        );
276
277        register_rest_route(
278            'beyondwords/v1',
279            '/projects/(?P<projectId>[0-9]+)',
280            [
281                'methods'             => \WP_REST_Server::READABLE,
282                'callback'            => [ self::class, 'rest_project_response' ],
283                'permission_callback' => static fn() => current_user_can( 'edit_posts' ),
284            ]
285        );
286
287        register_rest_route(
288            'beyondwords/v1',
289            '/summarization-settings-templates',
290            [
291                'methods'             => \WP_REST_Server::READABLE,
292                'callback'            => [ self::class, 'rest_summarization_settings_templates_response' ],
293                'permission_callback' => static fn() => current_user_can( 'edit_posts' ),
294            ]
295        );
296
297        register_rest_route(
298            'beyondwords/v1',
299            '/video-settings-templates',
300            [
301                'methods'             => \WP_REST_Server::READABLE,
302                'callback'            => [ self::class, 'rest_video_settings_templates_response' ],
303                'permission_callback' => static fn() => current_user_can( 'edit_posts' ),
304            ]
305        );
306    }
307
308    /**
309     * Settings payload for editor scripts.
310     *
311     * Never include the API key in this response — editor scripts run in the
312     * browser and the key must stay server-side.
313     */
314    public static function rest_settings_response(): \WP_REST_Response {
315        global $wp_version;
316
317        return new \WP_REST_Response(
318            [
319                'inspectMetaKeys'   => self::inspect_meta_keys(),
320                'integrationMethod' => Fields::get_integration_method(),
321                'pluginVersion'     => BEYONDWORDS__PLUGIN_VERSION,
322                'projectId'         => (string) get_option( Fields::OPTION_PROJECT_ID, '' ),
323                'preselect'         => Preselect::get(),
324                'restUrl'           => get_rest_url(),
325                'wpVersion'         => $wp_version,
326            ]
327        );
328    }
329
330    /**
331     * Meta keys surfaced in the editor Inspect panel.
332     *
333     * Sourced from the canonical lists in \BeyondWords\Core\Utils, minus
334     * internal-only keys that have never been shown in the panel.
335     *
336     * @since 7.0.0
337     *
338     * @return array{current: string[], deprecated: string[]}
339     */
340    private static function inspect_meta_keys(): array {
341        $internal_only = [
342            'beyondwords_player_content',
343            'beyondwords_player_style',
344            'beyondwords_title_voice_id',
345            'beyondwords_summary_voice_id',
346            'beyondwords_disabled',
347            'beyondwords_hash',
348            'speechkit_hash',
349            'speechkit_updated_at',
350        ];
351
352        return [
353            'current'    => \BeyondWords\Core\Utils::get_post_meta_keys( 'current' ),
354            'deprecated' => array_values(
355                array_diff(
356                    \BeyondWords\Core\Utils::get_post_meta_keys( 'deprecated' ),
357                    $internal_only
358                )
359            ),
360        ];
361    }
362
363    /**
364     * Mark the review notice as dismissed.
365     */
366    public static function rest_dismiss_review_notice(): \WP_REST_Response {
367        $saved = update_option( 'beyondwords_notice_review_dismissed', gmdate( \DateTime::ATOM ) );
368
369        return new \WP_REST_Response(
370            [ 'success' => $saved ],
371            $saved ? 200 : 500
372        );
373    }
374
375    /**
376     * Proxy for the BeyondWords video settings endpoint.
377     *
378     * Editor scripts read the `sizes` array to populate the "Video size" dropdown.
379     *
380     * @since 7.0.0
381     *
382     * @param \WP_REST_Request $request The REST request.
383     */
384    public static function rest_video_settings_response( \WP_REST_Request $request ): \WP_REST_Response {
385        $project_id = (int) $request->get_param( 'projectId' );
386        $response   = \BeyondWords\Api\Client::get_video_settings( $project_id );
387
388        return new \WP_REST_Response( $response );
389    }
390
391    /**
392     * Proxy for the BeyondWords project endpoint.
393     *
394     * Editor scripts read the project's default `language` for the Language dropdown.
395     *
396     * @since 7.0.0
397     *
398     * @param \WP_REST_Request $request The REST request.
399     */
400    public static function rest_project_response( \WP_REST_Request $request ): \WP_REST_Response {
401        $project_id = (int) $request->get_param( 'projectId' );
402        $response   = \BeyondWords\Api\Client::get_project( $project_id );
403
404        return new \WP_REST_Response( $response );
405    }
406
407    /**
408     * Proxy for the BeyondWords summarization settings templates endpoint.
409     *
410     * Editor scripts use this list to populate the "Script template" dropdown.
411     *
412     * @since 7.0.0
413     */
414    public static function rest_summarization_settings_templates_response(): \WP_REST_Response {
415        $response = \BeyondWords\Api\Client::get_summarization_settings_templates();
416
417        return new \WP_REST_Response( $response );
418    }
419
420    /**
421     * Proxy for the BeyondWords video settings templates endpoint.
422     *
423     * Editor scripts use this list to populate the "Video template" dropdown.
424     *
425     * @since 7.0.0
426     */
427    public static function rest_video_settings_templates_response(): \WP_REST_Response {
428        $response = \BeyondWords\Api\Client::get_video_settings_templates();
429
430        return new \WP_REST_Response( $response );
431    }
432}