Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
51 / 51 |
|
100.00% |
6 / 6 |
CRAP | |
100.00% |
1 / 1 |
| Utils | |
100.00% |
51 / 51 |
|
100.00% |
6 / 6 |
17 | |
100.00% |
1 / 1 |
| get_compatible_post_types | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| post_type_supports_required_features | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
4 | |||
| has_api_creds | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| has_valid_api_connection | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| validate_api_connection | |
100.00% |
30 / 30 |
|
100.00% |
1 / 1 |
6 | |||
| add_settings_error_message | |
100.00% |
7 / 7 |
|
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 | |
| 10 | declare( strict_types = 1 ); |
| 11 | |
| 12 | namespace BeyondWords\Settings; |
| 13 | |
| 14 | defined( 'ABSPATH' ) || exit; |
| 15 | |
| 16 | /** |
| 17 | * Settings utilities. |
| 18 | * |
| 19 | * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods. |
| 20 | */ |
| 21 | class 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 | } |