# speechkit/7.0.0/src/settings/class-settings.php

BeyondWords – AI audio for publishers, version 7.0.0. 433 lines.

- Page: https://pluginprobe.com/plugins/speechkit/7.0.0/code/src/settings/class-settings.php
- Raw: https://pluginprobe.com/plugins/speechkit/7.0.0/raw/src/settings/class-settings.php
- Modified: 2026-08-12T09:46:22+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/speechkit/7.0.0/code/src/settings/class-settings.php#L10-L20`.

```php
<?php
/**
 * BeyondWords settings page: admin menu, tabbed form, REST endpoints, notices.
 *
 * @package BeyondWords\Settings
 *
 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
 */

declare( strict_types = 1 );

namespace BeyondWords\Settings;

defined( 'ABSPATH' ) || exit;

/**
 * Settings page.
 *
 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
 */
class Settings {

	const PAGE_SLUG            = 'beyondwords';
	const REVIEW_NOTICE_OFFSET = '-14 days';

	/**
	 * Register WordPress hooks.
	 */
	public static function init(): void {
		add_action( 'admin_menu', [ self::class, 'add_options_page' ], 1 );
		add_action( 'admin_notices', [ self::class, 'maybe_print_missing_creds_warning' ], 100 );
		add_action( 'admin_notices', [ self::class, 'print_settings_errors' ], 200 );
		add_action( 'admin_notices', [ self::class, 'maybe_print_review_notice' ] );
		add_action( 'load-settings_page_' . self::PAGE_SLUG, [ self::class, 'maybe_validate_api_creds' ] );

		add_action( 'rest_api_init', [ self::class, 'register_rest_routes' ] );

		add_filter( 'plugin_action_links_speechkit/speechkit.php', [ self::class, 'add_plugin_action_link' ] );
	}

	/**
	 * Register the BeyondWords settings page under Settings.
	 */
	public static function add_options_page(): void {
		add_options_page(
			__( 'BeyondWords Settings', 'speechkit' ),
			__( 'BeyondWords', 'speechkit' ),
			'manage_options',
			self::PAGE_SLUG,
			[ self::class, 'render_admin_page' ]
		);
	}

	/**
	 * Validate API credentials whenever the Authentication tab loads.
	 *
	 * Triggered on the page-specific load hook so we don't pay the API cost
	 * on unrelated admin screens.
	 */
	public static function maybe_validate_api_creds(): void {
		if ( Tabs::TAB_AUTHENTICATION === Tabs::get_active_tab() ) {
			Utils::validate_api_connection();
		}
	}

	/**
	 * Render the settings page (the tabbed form).
	 */
	public static function render_admin_page(): void {
		$tabs       = Tabs::get_visible_tabs();
		$active_tab = Tabs::get_active_tab();
		$active     = Tabs::get_active_page_and_group();
		?>
		<div class="wrap">
			<h1><?php esc_html_e( 'BeyondWords Settings', 'speechkit' ); ?></h1>

			<form
				id="beyondwords-plugin-settings"
				action="<?php echo esc_url( admin_url( 'options.php' ) ); ?>"
				method="post"
			>
				<nav class="nav-tab-wrapper">
					<ul>
						<?php foreach ( $tabs as $slug => $label ) : ?>
							<li>
								<a
									class="nav-tab <?php echo $slug === $active_tab ? 'nav-tab-active' : ''; ?>"
									href="<?php echo esc_url( add_query_arg( [ 'page' => self::PAGE_SLUG, 'tab' => $slug ] ) ); ?>"
								>
									<?php echo esc_html( $label ); ?>
								</a>
							</li>
						<?php endforeach; ?>
					</ul>
				</nav>

				<hr class="wp-header-end">

				<?php
				settings_fields( $active['group'] );
				do_settings_sections( $active['page'] );
				submit_button( __( 'Save changes', 'speechkit' ) );
				?>
			</form>
		</div>
		<?php
	}

	/**
	 * Add a "Settings" link to the plugin row.
	 *
	 * @param string[] $links Existing action links.
	 * @return string[]
	 */
	public static function add_plugin_action_link( array $links ): array {
		$settings_link = sprintf(
			'<a href="%s">%s</a>',
			esc_url( admin_url( 'options-general.php?page=' . self::PAGE_SLUG ) ),
			esc_html__( 'Settings', 'speechkit' )
		);

		array_unshift( $links, $settings_link );
		return $links;
	}

	/**
	 * Show a banner pointing to the settings page until creds are entered.
	 */
	public static function maybe_print_missing_creds_warning(): void {
		if ( Utils::has_api_creds() ) {
			return;
		}
		?>
		<div class="notice notice-info">
			<p>
				<strong>
					<?php
					printf(
						/* translators: %s is the "plugin settings" link */
						esc_html__( 'To use BeyondWords, please update the %s.', 'speechkit' ),
						sprintf(
							'<a href="%s">%s</a>',
							esc_url( admin_url( 'options-general.php?page=' . self::PAGE_SLUG ) ),
							esc_html__( 'plugin settings', 'speechkit' )
						)
					);
					?>
				</strong>
			</p>
			<p><?php esc_html_e( 'Don’t have a BeyondWords account yet?', 'speechkit' ); ?></p>
			<p>
				<a
					class="button button-secondary"
					href="<?php echo esc_url( sprintf( '%s/auth/signup', \BeyondWords\Core\Urls::get_dashboard_url() ) ); ?>"
					target="_blank"
				>
					<?php esc_html_e( 'Sign up free', 'speechkit' ); ?>
				</a>
			</p>
		</div>
		<?php
	}

	/**
	 * Show the once-only review notice 14+ days after activation.
	 */
	public static function maybe_print_review_notice(): void {
		$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
		if ( ! $screen || 'settings_page_' . self::PAGE_SLUG !== $screen->id ) {
			return;
		}

		$dismissed = get_option( 'beyondwords_notice_review_dismissed', '' );
		if ( ! empty( $dismissed ) ) {
			return;
		}

		$activated_at = strtotime( (string) get_option( 'beyondwords_date_activated', '2025-03-01' ) );
		if ( false === $activated_at || $activated_at >= strtotime( self::REVIEW_NOTICE_OFFSET ) ) {
			return;
		}
		?>
		<div id="beyondwords_notice_review" class="notice notice-info is-dismissible">
			<p>
				<strong>
					<?php
					printf(
						/* translators: %s is the link to the WordPress plugin repo */
						esc_html__( 'Happy with our work? Help us spread the word with a rating on the %s.', 'speechkit' ),
						sprintf(
							'<a href="%s">%s</a>',
							'https://wordpress.org/support/plugin/speechkit/reviews/',
							esc_html__( 'WordPress Plugin Repo', 'speechkit' )
						)
					);
					?>
				</strong>
			</p>
		</div>
		<?php
	}

	/**
	 * Drain queued settings errors into a notice.
	 *
	 * `admin_notices` fires on every admin screen, so this early-returns off the
	 * settings page — the only screen the errors are ever queued for.
	 */
	public static function print_settings_errors(): void {
		$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
		if ( ! $screen || 'settings_page_' . self::PAGE_SLUG !== $screen->id ) {
			return;
		}

		$errors = get_transient( 'beyondwords_settings_errors' );

		if ( ! is_array( $errors ) || empty( $errors ) ) {
			return;
		}

		delete_transient( 'beyondwords_settings_errors' );

		$allowed = [
			'a'      => [ 'href' => [], 'target' => [] ],
			'b'      => [],
			'strong' => [],
			'i'      => [],
			'em'     => [],
			'br'     => [],
			'code'   => [],
		];
		?>
		<div class="notice notice-error">
			<ul class="ul-disc">
				<?php foreach ( $errors as $error ) : ?>
					<li><?php echo wp_kses( $error, $allowed ); ?></li>
				<?php endforeach; ?>
			</ul>
		</div>
		<?php
	}

	/**
	 * Register REST routes consumed by the editor scripts.
	 */
	public static function register_rest_routes(): void {
		register_rest_route(
			'beyondwords/v1',
			'/settings',
			[
				'methods'             => \WP_REST_Server::READABLE,
				'callback'            => [ self::class, 'rest_settings_response' ],
				'permission_callback' => static fn() => current_user_can( 'edit_posts' ),
			]
		);

		register_rest_route(
			'beyondwords/v1',
			'/settings/notices/review/dismiss',
			[
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => [ self::class, 'rest_dismiss_review_notice' ],
				'permission_callback' => static fn() => current_user_can( 'manage_options' ),
			]
		);

		register_rest_route(
			'beyondwords/v1',
			'/projects/(?P<projectId>[0-9]+)/video-settings',
			[
				'methods'             => \WP_REST_Server::READABLE,
				'callback'            => [ self::class, 'rest_video_settings_response' ],
				'permission_callback' => static fn() => current_user_can( 'edit_posts' ),
			]
		);

		register_rest_route(
			'beyondwords/v1',
			'/projects/(?P<projectId>[0-9]+)',
			[
				'methods'             => \WP_REST_Server::READABLE,
				'callback'            => [ self::class, 'rest_project_response' ],
				'permission_callback' => static fn() => current_user_can( 'edit_posts' ),
			]
		);

		register_rest_route(
			'beyondwords/v1',
			'/summarization-settings-templates',
			[
				'methods'             => \WP_REST_Server::READABLE,
				'callback'            => [ self::class, 'rest_summarization_settings_templates_response' ],
				'permission_callback' => static fn() => current_user_can( 'edit_posts' ),
			]
		);

		register_rest_route(
			'beyondwords/v1',
			'/video-settings-templates',
			[
				'methods'             => \WP_REST_Server::READABLE,
				'callback'            => [ self::class, 'rest_video_settings_templates_response' ],
				'permission_callback' => static fn() => current_user_can( 'edit_posts' ),
			]
		);
	}

	/**
	 * Settings payload for editor scripts.
	 *
	 * Never include the API key in this response — editor scripts run in the
	 * browser and the key must stay server-side.
	 */
	public static function rest_settings_response(): \WP_REST_Response {
		global $wp_version;

		return new \WP_REST_Response(
			[
				'inspectMetaKeys'   => self::inspect_meta_keys(),
				'integrationMethod' => Fields::get_integration_method(),
				'pluginVersion'     => BEYONDWORDS__PLUGIN_VERSION,
				'projectId'         => (string) get_option( Fields::OPTION_PROJECT_ID, '' ),
				'preselect'         => Preselect::get(),
				'restUrl'           => get_rest_url(),
				'wpVersion'         => $wp_version,
			]
		);
	}

	/**
	 * Meta keys surfaced in the editor Inspect panel.
	 *
	 * Sourced from the canonical lists in \BeyondWords\Core\Utils, minus
	 * internal-only keys that have never been shown in the panel.
	 *
	 * @since 7.0.0
	 *
	 * @return array{current: string[], deprecated: string[]}
	 */
	private static function inspect_meta_keys(): array {
		$internal_only = [
			'beyondwords_player_content',
			'beyondwords_player_style',
			'beyondwords_title_voice_id',
			'beyondwords_summary_voice_id',
			'beyondwords_disabled',
			'beyondwords_hash',
			'speechkit_hash',
			'speechkit_updated_at',
		];

		return [
			'current'    => \BeyondWords\Core\Utils::get_post_meta_keys( 'current' ),
			'deprecated' => array_values(
				array_diff(
					\BeyondWords\Core\Utils::get_post_meta_keys( 'deprecated' ),
					$internal_only
				)
			),
		];
	}

	/**
	 * Mark the review notice as dismissed.
	 */
	public static function rest_dismiss_review_notice(): \WP_REST_Response {
		$saved = update_option( 'beyondwords_notice_review_dismissed', gmdate( \DateTime::ATOM ) );

		return new \WP_REST_Response(
			[ 'success' => $saved ],
			$saved ? 200 : 500
		);
	}

	/**
	 * Proxy for the BeyondWords video settings endpoint.
	 *
	 * Editor scripts read the `sizes` array to populate the "Video size" dropdown.
	 *
	 * @since 7.0.0
	 *
	 * @param \WP_REST_Request $request The REST request.
	 */
	public static function rest_video_settings_response( \WP_REST_Request $request ): \WP_REST_Response {
		$project_id = (int) $request->get_param( 'projectId' );
		$response   = \BeyondWords\Api\Client::get_video_settings( $project_id );

		return new \WP_REST_Response( $response );
	}

	/**
	 * Proxy for the BeyondWords project endpoint.
	 *
	 * Editor scripts read the project's default `language` for the Language dropdown.
	 *
	 * @since 7.0.0
	 *
	 * @param \WP_REST_Request $request The REST request.
	 */
	public static function rest_project_response( \WP_REST_Request $request ): \WP_REST_Response {
		$project_id = (int) $request->get_param( 'projectId' );
		$response   = \BeyondWords\Api\Client::get_project( $project_id );

		return new \WP_REST_Response( $response );
	}

	/**
	 * Proxy for the BeyondWords summarization settings templates endpoint.
	 *
	 * Editor scripts use this list to populate the "Script template" dropdown.
	 *
	 * @since 7.0.0
	 */
	public static function rest_summarization_settings_templates_response(): \WP_REST_Response {
		$response = \BeyondWords\Api\Client::get_summarization_settings_templates();

		return new \WP_REST_Response( $response );
	}

	/**
	 * Proxy for the BeyondWords video settings templates endpoint.
	 *
	 * Editor scripts use this list to populate the "Video template" dropdown.
	 *
	 * @since 7.0.0
	 */
	public static function rest_video_settings_templates_response(): \WP_REST_Response {
		$response = \BeyondWords\Api\Client::get_video_settings_templates();

		return new \WP_REST_Response( $response );
	}
}

```
