Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
51 / 51
100.00% covered (success)
100.00%
6 / 6
CRAP
100.00% covered (success)
100.00%
1 / 1
Utils
100.00% covered (success)
100.00%
51 / 51
100.00% covered (success)
100.00%
6 / 6
17
100.00% covered (success)
100.00%
1 / 1
 get_compatible_post_types
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 post_type_supports_required_features
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 has_api_creds
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 has_valid_api_connection
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validate_api_connection
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
6
 add_settings_error_message
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * BeyondWords settings utilities.
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 utilities.
18 *
19 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
20 */
21class Utils {
22
23    /**
24     * Built-in post types BeyondWords never considers, regardless of `supports`.
25     *
26     * @var string[]
27     */
28    const SKIP_POST_TYPES = [
29        'attachment',
30        'custom_css',
31        'customize_changeset',
32        'nav_menu_item',
33        'oembed_cache',
34        'revision',
35        'user_request',
36        'wp_block',
37        'wp_font_face',
38        'wp_font_family',
39        'wp_template',
40        'wp_template_part',
41        'wp_global_styles',
42        'wp_navigation',
43    ];
44
45    /**
46     * Required `supports` features for a post type to be eligible.
47     *
48     * BeyondWords needs a title and an editor (body) to generate content,
49     * and custom fields to store post-level audio metadata.
50     *
51     * @var string[]
52     */
53    const REQUIRED_FEATURES = [ 'title', 'editor', 'custom-fields' ];
54
55    /**
56     * How long a connection check is trusted before re-validating.
57     */
58    const CONNECTION_CHECK_TTL = 5 * MINUTE_IN_SECONDS;
59
60    /**
61     * Transient that throttles connection re-validation.
62     *
63     * Holds a fingerprint of the last-checked credentials, so changing the API
64     * key or project ID busts the throttle and forces an immediate re-check.
65     */
66    const CONNECTION_CHECK_TRANSIENT = 'beyondwords_api_connection_checked';
67
68    /**
69     * Get the post types eligible for BeyondWords audio generation.
70     *
71     * @return string[]
72     */
73    public static function get_compatible_post_types(): array {
74        // Reindex before passing to the filter — callers expect a 0-indexed list,
75        // not the associative `[name => name]` shape `get_post_types()` returns.
76        $post_types = array_values( array_diff( get_post_types(), self::SKIP_POST_TYPES ) );
77
78        /**
79         * Filters the post types BeyondWords considers compatible.
80         *
81         * Types lacking the required `supports` features are still dropped.
82         *
83         * @since 3.3.3 Introduced as `beyondwords_post_types`.
84         * @since 4.3.0 Renamed to `beyondwords_settings_post_types`.
85         *
86         * @param string[] $post_types Candidate post type names.
87         */
88        $post_types = apply_filters( 'beyondwords_settings_post_types', $post_types );
89
90        $post_types = array_filter( $post_types, [ self::class, 'post_type_supports_required_features' ] );
91
92        return array_values( $post_types );
93    }
94
95    /**
96     * Whether a post type supports every feature BeyondWords requires.
97     *
98     * Unregistered types pass through — they were added by the
99     * `beyondwords_settings_post_types` filter, which we treat as authoritative.
100     *
101     * @param string $post_type Post type slug.
102     */
103    public static function post_type_supports_required_features( string $post_type ): bool {
104        if ( ! post_type_exists( $post_type ) ) {
105            return true;
106        }
107
108        foreach ( self::REQUIRED_FEATURES as $feature ) {
109            if ( ! post_type_supports( $post_type, $feature ) ) {
110                return false;
111            }
112        }
113
114        return true;
115    }
116
117    /**
118     * Whether both API key and project ID are set.
119     */
120    public static function has_api_creds(): bool {
121        $project_id = trim( (string) get_option( 'beyondwords_project_id' ) );
122        $api_key    = trim( (string) get_option( 'beyondwords_api_key' ) );
123
124        return '' !== $project_id && '' !== $api_key;
125    }
126
127    /**
128     * Whether the saved credentials produced a valid REST API connection.
129     *
130     * The flag is set the last time validation succeeded — it does not
131     * prove the credentials are still valid right now.
132     */
133    public static function has_valid_api_connection(): bool {
134        return (bool) get_option( 'beyondwords_valid_api_connection' );
135    }
136
137    /**
138     * Validate the BeyondWords REST API connection and persist the result.
139     *
140     * Throttled per credential fingerprint; only a definitive auth failure
141     * (401/403) clears the stored flag, so an API blip can't hide the other tabs.
142     */
143    public static function validate_api_connection(): bool {
144        $project_id = get_option( 'beyondwords_project_id' );
145        $api_key    = get_option( 'beyondwords_api_key' );
146
147        if ( ! $project_id || ! $api_key ) {
148            delete_option( 'beyondwords_valid_api_connection' );
149            return false;
150        }
151
152        $fingerprint = md5( (string) $project_id . '|' . (string) $api_key );
153
154        // Within the throttle window, trust the last result for these credentials.
155        if ( get_transient( self::CONNECTION_CHECK_TRANSIENT ) === $fingerprint ) {
156            return self::has_valid_api_connection();
157        }
158
159        $url      = sprintf( '%s/projects/%d', \BeyondWords\Core\Urls::get_api_url(), $project_id );
160        $response = \BeyondWords\Api\Client::call_api( 'GET', $url );
161
162        // Record the attempt whatever the outcome — a down API is throttled too.
163        set_transient( self::CONNECTION_CHECK_TRANSIENT, $fingerprint, self::CONNECTION_CHECK_TTL );
164
165        $response_code = wp_remote_retrieve_response_code( $response );
166
167        if ( 200 === (int) $response_code ) {
168            update_option( 'beyondwords_valid_api_connection', gmdate( \DateTime::ATOM ), false );
169            return true;
170        }
171
172        // 403 is a definitive auth failure (call_api() already handles 401); any
173        // other response is treated as transient and leaves the flag untouched.
174        if ( 403 === (int) $response_code ) {
175            delete_option( 'beyondwords_valid_api_connection' );
176        }
177
178        $debug = sprintf(
179            '<code>%s</code>: <code>%s</code>',
180            $response_code,
181            wp_remote_retrieve_body( $response )
182        );
183
184        self::add_settings_error_message(
185            sprintf(
186                /* translators: %s is replaced with the BeyondWords REST API response debug data */
187                __( 'We were unable to validate your BeyondWords REST API connection.<br />Please check your project ID and API key, save changes, and contact us for support if this message remains.<br /><br />BeyondWords REST API Response:<br />%s', 'speechkit' ),
188                $debug
189            ),
190            'Settings/ValidApiConnection'
191        );
192
193        return false;
194    }
195
196    /**
197     * Queue a settings error message for later rendering.
198     *
199     * Uses a short-lived transient rather than the object cache so the message
200     * survives the Settings API's post-save redirect on any host.
201     *
202     * @param string $message  Error message (HTML allowed; rendered through `wp_kses`).
203     * @param string $error_id Optional stable ID; auto-generated when blank.
204     */
205    public static function add_settings_error_message( string $message, string $error_id = '' ): void {
206        $errors = get_transient( 'beyondwords_settings_errors' );
207
208        if ( ! is_array( $errors ) ) {
209            $errors = [];
210        }
211
212        if ( '' === $error_id ) {
213            $error_id = bin2hex( random_bytes( 8 ) );
214        }
215
216        $errors[ $error_id ] = $message;
217
218        set_transient( 'beyondwords_settings_errors', $errors, 30 );
219    }
220}