|false|null Response from the API, or false when audio wasn't generated. */ public static function generate_audio_for_post( int $post_id ): array|false|null { return self::generate_audio_result( $post_id )['response']; } /** * Generate audio for a post, reporting whether it ran, was skipped, or failed. * * A falsy response on its own can't tell a post that had nothing to do from * one whose API call failed. See doc/async-rest-migration.md. * * @since 7.0.0 * * @return array{outcome:string, response:array|false|null} */ private static function generate_audio_result( int $post_id ): array { if ( ! self::should_generate_audio_for_post( $post_id ) ) { return self::skipped_result(); } $post = get_post( $post_id ); if ( ! $post ) { return self::skipped_result(); } $integration_method = \BeyondWords\Settings\Fields::get_integration_method( $post ); // Client-side integration is "Magic Embed": import via the by-source-id endpoint. if ( \BeyondWords\Settings\Fields::INTEGRATION_CLIENT_SIDE === $integration_method ) { update_post_meta( $post_id, 'beyondwords_integration_method', \BeyondWords\Settings\Fields::INTEGRATION_CLIENT_SIDE ); update_post_meta( $post_id, 'beyondwords_project_id', get_option( 'beyondwords_project_id' ) ); return self::attempted_result( \BeyondWords\Api\Client::get_player_by_source_id( $post_id ) ); } update_post_meta( $post_id, 'beyondwords_integration_method', \BeyondWords\Settings\Fields::INTEGRATION_REST_API ); $content_id = \BeyondWords\Post\Meta::get_content_id( $post_id ); if ( $content_id ) { if ( defined( 'BEYONDWORDS_AUTOREGENERATE' ) && ! BEYONDWORDS_AUTOREGENERATE ) { return self::skipped_result(); } $response = self::update_or_recreate_audio( $post_id ); } else { // Returns early: create_audio_once() stores its own response, under the lock. return self::create_audio_once( $post_id ); } $project_id = \BeyondWords\Post\Meta::get_project_id( $post_id ); self::process_response( $response, $project_id, $post_id ); return self::attempted_result( $response ); } /** * Result for a post that needed no work — never counted as a failure. * * @since 7.0.0 * * @return array{outcome:string, response:false} */ private static function skipped_result(): array { return [ 'outcome' => self::OUTCOME_SKIPPED, 'response' => false, ]; } /** * Result for an API call we actually made, where a falsy response is the failure signal. * * @since 7.0.0 * * @return array{outcome:string, response:array|false|null} */ private static function attempted_result( array|false|null $response ): array { return [ 'outcome' => $response ? self::OUTCOME_GENERATED : self::OUTCOME_FAILED, 'response' => $response, ]; } /** * Create audio for a post unless another request is already creating it. * * Stores its own response, so the content ID lands before the lock lifts. * See doc/source-id-race.md. * * @since 7.0.0 * * @return array{outcome:string, response:array|false|null} Skipped when another request has the create covered. */ private static function create_audio_once( int $post_id ): array { if ( ! self::acquire_create_lock( $post_id ) ) { return self::skipped_result(); } try { // The winner may have finished while we waited; the meta cache predates it. wp_cache_delete( $post_id, 'post_meta' ); if ( \BeyondWords\Post\Meta::get_content_id( $post_id ) ) { return self::skipped_result(); } $response = \BeyondWords\Api\Client::create_audio( $post_id ); self::process_response( $response, \BeyondWords\Post\Meta::get_project_id( $post_id ), $post_id ); return self::attempted_result( $response ); } finally { delete_post_meta( $post_id, self::CREATE_LOCK_META_KEY ); } } /** * Take the create lock for a post, stealing one left behind by a dead request. * * See doc/source-id-race.md. * * @since 7.0.0 */ private static function acquire_create_lock( int $post_id ): bool { $now = time(); // `$unique` tests for an existing row uncached, so the window is one statement. if ( add_post_meta( $post_id, self::CREATE_LOCK_META_KEY, (string) $now, true ) ) { return true; } // A meta cache predating the holder's write would read empty and steal a live lock. wp_cache_delete( $post_id, 'post_meta' ); $locked_at = (int) get_post_meta( $post_id, self::CREATE_LOCK_META_KEY, true ); if ( $now - $locked_at < self::CREATE_LOCK_TIMEOUT ) { return false; } update_post_meta( $post_id, self::CREATE_LOCK_META_KEY, (string) $now ); return true; } /** * Update audio for a post, recovering from a stale content ID. * * A `#404:…` error meta means the content no longer exists at BeyondWords, * so clear the stale IDs and create fresh content instead. * * @param int $post_id WordPress post ID. */ private static function update_or_recreate_audio( int $post_id ): array|null|false { $response = \BeyondWords\Api\Client::update_audio( $post_id ); $error_message = (string) get_post_meta( $post_id, 'beyondwords_error_message', true ); if ( str_starts_with( $error_message, '#404:' ) ) { delete_post_meta( $post_id, 'beyondwords_content_id' ); delete_post_meta( $post_id, 'beyondwords_podcast_id' ); delete_post_meta( $post_id, 'speechkit_podcast_id' ); $response = \BeyondWords\Api\Client::create_audio( $post_id ); } return $response; } /** * Delete audio for a single post (DELETE /content/:id). */ public static function delete_audio_for_post( int $post_id ): array|false|null { return \BeyondWords\Api\Client::delete_audio( $post_id ); } /** * Bulk-delete audio for multiple posts. * * @param int[] $post_ids */ public static function batch_delete_audio_for_posts( array $post_ids ): array|false|null { return \BeyondWords\Api\Client::batch_delete_audio( $post_ids ); } /** * Dispatch bulk "Generate audio" for a set of posts. * * On VIP each post is queued as a background cron job; off VIP generation * runs inline, capped at BULK_GENERATE_SYNC_LIMIT with the rest deferred. * * @since 7.0.0 * * @param int[] $post_ids WordPress post IDs from the bulk selection. * * @return array{generated:int, failed:int, skipped:int, deferred:int} Per-outcome counts. */ public static function bulk_generate_audio_for_posts( array $post_ids ): array { $post_ids = array_map( 'intval', $post_ids ); $post_ids = array_values( array_unique( array_filter( $post_ids, static fn( int $id ): bool => $id > 0 ) ) ); sort( $post_ids ); // The async cron job reads this flag; off VIP it records intent for the // posts deferred past the cap. foreach ( $post_ids as $post_id ) { update_post_meta( $post_id, 'beyondwords_generate_audio', '1' ); } if ( self::is_async_generation_enabled() ) { foreach ( $post_ids as $post_id ) { self::schedule_audio_generation( $post_id ); } return [ 'generated' => count( $post_ids ), 'failed' => 0, 'skipped' => 0, 'deferred' => 0, ]; } // Each call is bounded by the client's short timeout, so the cap is what // keeps the batch total inside execution limits. $ordered = self::order_posts_for_bulk_generation( $post_ids ); $limit = self::BULK_GENERATE_SYNC_LIMIT; $to_process = array_slice( $ordered, 0, $limit ); $deferred = count( $ordered ) - count( $to_process ); $generated = 0; $failed = 0; $skipped = 0; foreach ( $to_process as $post_id ) { $outcome = self::generate_audio_result( $post_id )['outcome']; if ( self::OUTCOME_GENERATED === $outcome ) { ++$generated; } elseif ( self::OUTCOME_SKIPPED === $outcome ) { ++$skipped; } else { ++$failed; } } return [ 'generated' => $generated, 'failed' => $failed, 'skipped' => $skipped, 'deferred' => $deferred, ]; } /** * Order a bulk selection so posts needing audio precede regenerations. * * Keeps the synchronous cap making forward progress: re-running the action * works through un-generated posts instead of re-updating the same ones. * * @param int[] $post_ids Normalised, sorted post IDs. * * @return int[] */ private static function order_posts_for_bulk_generation( array $post_ids ): array { $needs_create = []; $needs_update = []; foreach ( $post_ids as $post_id ) { if ( \BeyondWords\Post\Meta::get_content_id( $post_id ) ) { $needs_update[] = $post_id; } else { $needs_create[] = $post_id; } } return array_merge( $needs_create, $needs_update ); } /** * Run a deferred audio deletion queued by the trash/delete handlers. * * @since 7.0.0 * * @param int|string $project_id BeyondWords project ID. * @param int|string $content_id BeyondWords content ID. */ public static function delete_audio_by_ids( int|string $project_id, int|string $content_id ): array|false|null { return \BeyondWords\Api\Client::delete_audio_by_ids( $project_id, $content_id ); } /** * Persist relevant fields from a BeyondWords API response into post meta. * * @param mixed $response API response (typically an associative array). * @param int|string|false $project_id BeyondWords project ID. * @param int $post_id WordPress post ID. * * @return mixed The response, unchanged. */ public static function process_response( mixed $response, int|string|false $project_id, int $post_id ): mixed { if ( ! is_array( $response ) ) { return $response; } if ( $project_id && ! empty( $response['id'] ) ) { update_post_meta( $post_id, 'beyondwords_project_id', $project_id ); update_post_meta( $post_id, 'beyondwords_content_id', $response['id'] ); // Deliberately don't copy `language`/`body_voice_id` back: those keys hold // explicit editor choices, and echoing the API-resolved project defaults // would freeze them. See Content::get_content_params(). $copy = [ 'preview_token' => 'beyondwords_preview_token', ]; foreach ( $copy as $api_key => $meta_key ) { if ( ! empty( $response[ $api_key ] ) ) { update_post_meta( $post_id, $meta_key, $response[ $api_key ] ); } } } return $response; } /** * Register every BeyondWords post-meta key for REST + auth gating. * * Only keys the block editor reads/writes over REST get `show_in_rest`; * secrets and internal data never reach the REST API. */ public static function register_meta(): void { $post_types = \BeyondWords\Settings\Utils::get_compatible_post_types(); if ( ! is_array( $post_types ) ) { return; } $keys = \BeyondWords\Core\Utils::get_post_meta_keys( 'all' ); $rest_keys = array_merge( \BeyondWords\Core\Utils::get_post_meta_keys( 'current' ), self::REST_LEGACY_META_KEYS ); foreach ( $post_types as $post_type ) { foreach ( $keys as $key ) { // Content IDs are interpolated into API URL paths, so REST writes need // the same strict validation as the classic editor. $sanitize_callback = 'beyondwords_content_id' === $key ? [ \BeyondWords\Post\Meta::class, 'sanitize_content_id' ] : 'sanitize_text_field'; register_meta( 'post', $key, [ 'show_in_rest' => in_array( $key, $rest_keys, true ), 'single' => true, 'type' => 'string', 'default' => '', 'object_subtype' => $post_type, 'prepare_callback' => 'sanitize_text_field', 'sanitize_callback' => $sanitize_callback, 'auth_callback' => static fn(): bool => current_user_can( 'edit_posts' ), ] ); } } } /** * Register the REST filter that hides private BeyondWords meta, per post type. * * @since 7.0.0 */ public static function register_rest_meta_visibility(): void { $post_types = \BeyondWords\Settings\Utils::get_compatible_post_types(); if ( ! is_array( $post_types ) ) { return; } foreach ( $post_types as $post_type ) { add_filter( "rest_prepare_{$post_type}", [ self::class, 'hide_private_meta_from_rest' ], 10, 3 ); } } /** * Strip secret/internal BeyondWords meta from non-`edit` REST responses. * * `WP_REST_Meta_Fields` returns `show_in_rest` meta in the public `view` * context with no capability check; the `edit` context is permission-gated. * * @since 7.0.0 * * @param \WP_REST_Response $response The response object. * @param \WP_Post $post The post the response is for. * @param \WP_REST_Request $request The request object. * * @return \WP_REST_Response */ public static function hide_private_meta_from_rest( $response, $post, $request ) { if ( ! $response instanceof \WP_REST_Response ) { return $response; } if ( 'edit' === $request->get_param( 'context' ) ) { return $response; } $data = $response->get_data(); if ( ! is_array( $data ) || empty( $data['meta'] ) || ! is_array( $data['meta'] ) ) { return $response; } foreach ( self::REST_PRIVATE_META_KEYS as $key ) { unset( $data['meta'][ $key ] ); } $response->set_data( $data ); return $response; } /** * Hide BeyondWords meta from the legacy "Custom Fields" panel. * * The panel can break when our meta renders alongside the auto-rendered * controls — https://github.com/WordPress/gutenberg/issues/23078. * * @param bool|null $is_protected Whether the meta is currently flagged protected. * @param string|null $meta_key Meta key being checked. Null is passed by some core paths. */ public static function is_protected_meta( $is_protected, $meta_key ): bool { if ( null === $meta_key ) { return (bool) $is_protected; } if ( in_array( $meta_key, \BeyondWords\Core\Utils::get_post_meta_keys( 'all' ), true ) ) { return true; } return (bool) $is_protected; } /** * Schedule a deferred audio-generation cron event for a post. * * No-ops when an event for this post is already queued, so repeated saves * don't stack duplicate jobs. * * @since 7.0.0 */ private static function schedule_audio_generation( int $post_id ): void { if ( wp_next_scheduled( self::GENERATE_AUDIO_CRON_HOOK, [ $post_id ] ) ) { return; } if ( function_exists( 'wpcom_vip_schedule_single_event' ) ) { wpcom_vip_schedule_single_event( time(), self::GENERATE_AUDIO_CRON_HOOK, [ $post_id ] ); return; } wp_schedule_single_event( time(), self::GENERATE_AUDIO_CRON_HOOK, [ $post_id ] ); } /** * Schedule a deferred audio-deletion cron event. * * Mirrors `schedule_audio_generation()`, including the duplicate-event guard. * * @since 7.0.0 */ private static function schedule_audio_deletion( int|string $project_id, int|string $content_id ): void { $args = [ $project_id, $content_id ]; if ( wp_next_scheduled( self::DELETE_AUDIO_CRON_HOOK, $args ) ) { return; } if ( function_exists( 'wpcom_vip_schedule_single_event' ) ) { wpcom_vip_schedule_single_event( time(), self::DELETE_AUDIO_CRON_HOOK, $args ); return; } wp_schedule_single_event( time(), self::DELETE_AUDIO_CRON_HOOK, $args ); } /** * Clear any pending audio-generation cron event for a post. * * Runs before the `has_content()` checks in the lifecycle handlers because * a queued post may not have written its content meta yet. * * @since 7.0.0 */ private static function unschedule_audio_generation( int $post_id ): void { wp_clear_scheduled_hook( self::GENERATE_AUDIO_CRON_HOOK, [ $post_id ] ); } /** * Delete a post's BeyondWords audio, deferring to background cron on VIP. * * The IDs are captured now because the caller wipes the meta (trash) or * WordPress deletes the row (permanent delete) before a deferred job runs. * * @since 7.0.0 */ private static function delete_audio_for_post_or_defer( int $post_id ): void { if ( self::is_async_generation_enabled() ) { $project_id = \BeyondWords\Post\Meta::get_project_id( $post_id ); $content_id = \BeyondWords\Post\Meta::get_content_id( $post_id, true ); if ( $project_id && $content_id ) { self::schedule_audio_deletion( $project_id, $content_id ); } return; } self::delete_audio_for_post( $post_id ); } /** * Trash hook: delete the remote audio, then remove our local metadata. */ public static function on_trash_post( $post_id ): void { $post_id = (int) $post_id; self::unschedule_audio_generation( $post_id ); if ( ! \BeyondWords\Post\Meta::has_content( $post_id ) ) { return; } self::delete_audio_for_post_or_defer( $post_id ); \BeyondWords\Post\Meta::remove_all_beyondwords_metadata( $post_id ); } /** * Permanent delete hook: same as trash, minus the meta cleanup. */ public static function on_delete_post( $post_id ): void { $post_id = (int) $post_id; self::unschedule_audio_generation( $post_id ); if ( ! \BeyondWords\Post\Meta::has_content( $post_id ) ) { return; } self::delete_audio_for_post_or_defer( $post_id ); } /** * `wp_after_insert_post` hook. * * Skips Gutenberg's second invocation via the meta-box save round-trip, * which would otherwise double-process every save. */ public static function on_add_or_update_post( $post_id ): bool { $post_id = (int) $post_id; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( isset( $_REQUEST['meta-box-loader'] ) && '' !== sanitize_key( wp_unslash( $_REQUEST['meta-box-loader'] ) ) ) { return false; } if ( '1' === get_post_meta( $post_id, 'beyondwords_delete_content', true ) ) { self::delete_audio_for_post( $post_id ); \BeyondWords\Post\Meta::remove_all_beyondwords_metadata( $post_id ); return false; } // Eligibility is re-checked inside generate_audio_for_post() when the // deferred job runs. if ( self::is_async_generation_enabled() && self::should_generate_audio_for_post( $post_id ) ) { self::schedule_audio_generation( $post_id ); return true; } return (bool) self::generate_audio_for_post( $post_id ); } /** * Back-fill `beyondwords_language_code` from the legacy numeric language ID. * * @param mixed $value Existing meta value. * @param int $object_id Post ID. * @param string|null $meta_key Meta key being read. * * @return mixed */ public static function get_lang_code_from_json_if_empty( $value, $object_id, $meta_key ): mixed { if ( 'beyondwords_language_code' !== $meta_key || ! empty( $value ) ) { return $value; } $language_id = get_post_meta( $object_id, 'beyondwords_language_id', true ); if ( ! $language_id ) { return $value; } $lang_codes = wp_json_file_decode( BEYONDWORDS__PLUGIN_DIR . 'assets/lang-codes.json', [ 'associative' => true ] ); if ( is_array( $lang_codes ) && array_key_exists( $language_id, $lang_codes ) ) { return [ $lang_codes[ $language_id ] ]; } return $value; } }