* @since 3.5.0 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods. */ defined( 'ABSPATH' ) || exit; class Content { public const DATE_FORMAT = 'Y-m-d\TH:i:s\Z'; /** * Get the content "body" param for the audio. * * The excerpt is prepended to the body because API v1.1 repurposed the * "summary" param. * * @since 4.6.0 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods. * * @param int|\WP_Post $post The WordPress post ID, or post object. * * @return string The content body param. */ public static function get_content_body( int|\WP_Post $post ): string|null { $post = get_post( $post ); if ( ! ( $post instanceof \WP_Post ) ) { throw new \Exception( esc_html__( 'Post Not Found', 'speechkit' ) ); } $summary = self::get_post_summary( $post ); $body = self::get_post_body( $post ); if ( $summary ) { $format = self::get_post_summary_wrapper_format( $post ); $body = sprintf( $format, $summary ) . $body; } return $body; } /** * Get the post body for the audio content. * * @since 3.0.0 * @since 3.5.0 Moved from Core\Utils to Component\Post\PostUtils * @since 3.8.0 Exclude Gutenberg blocks with attribute { beyondwordsAudio: false } * @since 4.0.0 Renamed from Content::getSourceTextForAudio() to Content::getBody() * @since 4.6.0 Renamed from Content::getBody() to Content::get_post_body() * @since 4.7.0 Remove wpautop filter for block editor API requests. * @since 5.0.0 Remove SpeechKit-Start shortcode. * @since 5.0.0 Remove beyondwords_content filter. * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods. * * @param int|\WP_Post $post The WordPress post ID, or post object. * * @return string The body (the processed $post->post_content). */ public static function get_post_body( int|\WP_Post $post ): string|null { $post = get_post( $post ); if ( ! ( $post instanceof \WP_Post ) ) { throw new \Exception( esc_html__( 'Post Not Found', 'speechkit' ) ); } $content = self::get_content_without_excluded_blocks( $post ); if ( has_blocks( $post ) ) { // wpautop breaks our HTML markup when block editor paragraphs are empty, // but we still want to remove the empty lines it would have handled. remove_filter( 'the_content', 'wpautop' ); $content = preg_replace( '/^\h*\v+/m', '', $content ); } // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Applying core WordPress filter $content = apply_filters( 'the_content', $content ); return trim( $content ); } /** * Get the post summary wrapper format. * * @since 4.6.0 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods. * * @param int|\WP_Post $post The WordPress post ID, or post object. * * @return string The summary wrapper
. */ public static function get_post_summary_wrapper_format( int|\WP_Post $post ): string { $post = get_post( $post ); if ( ! ( $post instanceof \WP_Post ) ) { throw new \Exception( esc_html__( 'Post Not Found', 'speechkit' ) ); } return '
%s
'; } /** * Get the post summary for the audio content. * * @since 4.0.0 * @since 4.6.0 Renamed from Content::getSummary() to Content::get_post_summary() * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods. * * @param int|\WP_Post $post The WordPress post ID, or post object. * * @return string The summary. */ public static function get_post_summary( int|\WP_Post $post ): string|null { $post = get_post( $post ); if ( ! ( $post instanceof \WP_Post ) ) { throw new \Exception( esc_html__( 'Post Not Found', 'speechkit' ) ); } $summary = null; $prepend_excerpt = get_option( 'beyondwords_prepend_excerpt' ); if ( $prepend_excerpt && has_excerpt( $post ) ) { $summary = htmlentities( $post->post_excerpt, ENT_QUOTES | ENT_XHTML ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Applying core WordPress filter $summary = apply_filters( 'get_the_excerpt', $summary ); $summary = trim( wpautop( $summary ) ); } return $summary; } /** * Get the post content without the blocks an editor excluded from audio. * * @since 3.8.0 * @since 4.0.0 Replace for loop with array_reduce * @since 6.0.0 Remove beyondwordsMarker attribute from rendered blocks. * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods. * @since 7.1.0 Render the per-block language and voice data attributes. * * @param int|\WP_Post $post The WordPress post ID, or post object. * * @return string The post body without excluded blocks. */ public static function get_content_without_excluded_blocks( int|\WP_Post $post ): string { $post = get_post( $post ); if ( ! ( $post instanceof \WP_Post ) ) { throw new \Exception( esc_html__( 'Post Not Found', 'speechkit' ) ); } if ( ! has_blocks( $post ) ) { return trim( $post->post_content ); } $output = ''; $blocks = self::get_audio_enabled_blocks( $post ); $segment_attributes = [ \BeyondWords\Editor\Components\BlockAttributes::class, 'add_segment_attributes' ]; // Per-block voices ride in the API body only, so the front end renders unchanged. add_filter( 'render_block', $segment_attributes, 10, 2 ); try { foreach ( $blocks as $block ) { $output .= render_block( $block ); } } finally { remove_filter( 'render_block', $segment_attributes, 10 ); } return $output; } /** * Get audio-enabled blocks. * * @since 4.0.0 * @since 5.0.0 Remove beyondwords_post_audio_enabled_blocks filter. * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods. * @since 7.1.0 Exclude blocks at any depth, not just top-level ones. * * @param int|\WP_Post $post The WordPress post ID, or post object. * * @return array The blocks. */ public static function get_audio_enabled_blocks( int|\WP_Post $post ): array { $post = get_post( $post ); if ( ! ( $post instanceof \WP_Post ) ) { return []; } if ( ! has_blocks( $post ) ) { return []; } return self::filter_audio_enabled_blocks( parse_blocks( $post->post_content ) ); } /** * Drop the blocks an editor excluded from the audio, at every depth. * * @since 7.1.0 */ private static function filter_audio_enabled_blocks( array $blocks ): array { $kept = []; foreach ( $blocks as $block ) { if ( ! self::is_audio_enabled_block( $block ) ) { continue; } $kept[] = empty( $block['innerBlocks'] ) ? $block : self::without_excluded_inner_blocks( $block ); } return $kept; } /** * Whether a parsed block is included in the audio. * * @since 7.1.0 */ private static function is_audio_enabled_block( $block ): bool { if ( ! is_array( $block ) || ! is_array( $block['attrs'] ?? null ) ) { return true; } if ( ! isset( $block['attrs']['beyondwordsAudio'] ) ) { return true; } return (bool) $block['attrs']['beyondwordsAudio']; } /** * Drop a block's excluded descendants. * * `innerContent` carries one null per inner block, in order, and is what * render_block() walks — so dropping a child means dropping its placeholder * too, or the remaining children render in the wrong places. * * @since 7.1.0 */ private static function without_excluded_inner_blocks( array $block ): array { $inner_blocks = []; $inner_content = []; $index = 0; foreach ( (array) ( $block['innerContent'] ?? [] ) as $chunk ) { if ( null !== $chunk ) { $inner_content[] = $chunk; continue; } $child = $block['innerBlocks'][ $index ] ?? null; ++$index; if ( ! is_array( $child ) || ! self::is_audio_enabled_block( $child ) ) { continue; } $inner_blocks[] = empty( $child['innerBlocks'] ) ? $child : self::without_excluded_inner_blocks( $child ); $inner_content[] = null; } $block['innerBlocks'] = $inner_blocks; $block['innerContent'] = $inner_content; return $block; } /** * Get the body param we pass to the API. * * @since 3.0.0 Introduced as getBodyJson. * @since 3.3.0 Added metadata to aid custom playlist generation. * @since 3.5.0 Moved from Core\Utils to Component\Post\PostUtils. * @since 3.10.4 Rename `published_at` API param to `publish_date`. * @since 4.0.0 Use new API params. * @since 4.0.3 Ensure `image_url` is always a string. * @since 4.3.0 Rename from getBodyJson to getContentParams. * @since 4.6.0 Remove summary param & prepend body with summary. * @since 5.0.0 Remove beyondwords_body_params filter. * @since 6.0.0 Cast return value to string. * @since 7.0.0 Replace the `metadata` param with a flat `tags` array. * * @static * @param int $post_id WordPress Post ID. * * @return string JSON encoded params. **/ public static function get_content_params( int $post_id ): array|string { $body = [ 'type' => 'auto_segment', 'title' => get_the_title( $post_id ), 'body' => self::get_content_body( $post_id ), 'source_url' => get_the_permalink( $post_id ), 'source_id' => strval( $post_id ), 'author' => self::get_author_name( $post_id ), 'image_url' => strval( wp_get_original_image_url( get_post_thumbnail_id( $post_id ) ) ), 'tags' => self::get_tags( $post_id ), 'publish_date' => get_post_time( self::DATE_FORMAT, true, $post_id ), ]; $status = get_post_status( $post_id ); // Drafts send { published: false } to keep the audio out of playlists, and // omit publish_date because get_post_time() is false for pending posts. if ( in_array( $status, [ 'draft', 'pending'] ) ) { $body['published'] = false; unset( $body['publish_date'] ); } else { /** * Filters whether generated content is auto-published to BeyondWords. * * Replaces the v6.x `beyondwords_project_auto_publish_enabled` setting. * * @since 7.0.0 * * @param bool $auto_publish Whether to mark generated content as published. * @param int $post_id WordPress post ID. */ $auto_publish = apply_filters( 'beyondwords_auto_publish', true, $post_id ); if ( $auto_publish ) { $body['published'] = true; } } // The language is never sent: a chosen voice implies it, and with no voice // the project default applies. `beyondwords_language_code` is editor state only. $body_voice_id = intval( get_post_meta( $post_id, 'beyondwords_body_voice_id', true ) ); if ( $body_voice_id > 0 ) { $body['body_voice_id'] = $body_voice_id; } // Omitted when Source is Post (or unset) so the project default applies. $source = \BeyondWords\Editor\Components\SettingsFields::get_source( $post_id ); if ( \BeyondWords\Editor\Components\SettingsFields::source_includes_script( $source ) ) { $body['summarization_settings'] = [ 'enabled' => true ]; $script_template_id = intval( get_post_meta( $post_id, 'beyondwords_script_template_id', true ) ); if ( $script_template_id > 0 ) { $body['summarization_settings']['template'] = [ 'id' => $script_template_id, ]; } } $output = get_post_meta( $post_id, 'beyondwords_output', true ); if ( in_array( $output, [ 'video', 'audio_and_video' ], true ) ) { $body['video_settings'] = self::get_video_settings_params( $post_id ); } /** * Filters the params we send to the BeyondWords API 'content' endpoint. * * @since 4.0.0 Introduced as beyondwords_body_params * @since 4.3.0 Renamed from beyondwords_body_params to beyondwords_content_params * * @param array $body The params we send to the BeyondWords API. * @param array $post_id WordPress post ID. */ $body = apply_filters( 'beyondwords_content_params', $body, $post_id ); return (string) wp_json_encode( $body ); } /** * Build the `video_settings` param sent to the BeyondWords content endpoint. * * The backend silently skips video generation unless the payload has `enabled: * true` plus non-empty `variants` and `sizes` (with dimensions), so we seed * from the project defaults and layer the post's choices on top. See doc/video-settings-payload.md. * * @since 7.0.0 * * @param int $post_id WordPress post ID. * * @return array The `video_settings` param. */ private static function get_video_settings_params( int $post_id ): array { // The post's project may be a per-post override of the global one. $project_id = \BeyondWords\Post\Meta::get_project_id( $post_id ); $defaults = \BeyondWords\Api\Client::get_video_settings( is_numeric( $project_id ) ? (int) $project_id : null ); $defaults = is_array( $defaults ) ? $defaults : []; $settings = [ 'enabled' => true ]; // The backend needs a non-empty `variants`; there is no per-post variant // control, so echo the project defaults. if ( ! empty( $defaults['variants'] ) && is_array( $defaults['variants'] ) ) { $settings['variants'] = array_values( $defaults['variants'] ); } // Echo the project sizes (the backend requires width/height), enabling // only the post's chosen size when one is set. $video_size = (string) get_post_meta( $post_id, 'beyondwords_video_size', true ); $default_sizes = ( isset( $defaults['sizes'] ) && is_array( $defaults['sizes'] ) ) ? $defaults['sizes'] : []; $sizes = []; foreach ( $default_sizes as $size ) { if ( ! is_array( $size ) || ! isset( $size['name'] ) ) { continue; } $sizes[] = [ 'name' => (string) $size['name'], 'width' => (int) ( $size['width'] ?? 0 ), 'height' => (int) ( $size['height'] ?? 0 ), 'enabled' => '' !== $video_size ? ( (string) $size['name'] === $video_size ) : (bool) ( $size['enabled'] ?? false ), ]; } if ( ! empty( $sizes ) ) { $settings['sizes'] = $sizes; } // Omit `template` to defer to the project default. $video_template_id = intval( get_post_meta( $post_id, 'beyondwords_video_template_id', true ) ); if ( $video_template_id > 0 ) { $settings['template'] = [ 'id' => $video_template_id ]; } return $settings; } /** * Get the taxonomy terms to send as the `tags` param. * * The values are used to create playlist filters in the BeyondWords dashboard. * * @since 3.3.0 Introduced as get_metadata(), sending a metadata.taxonomy object. * @since 3.5.0 Moved from Core\Utils to Component\Post\PostUtils. * @since 5.0.0 Remove beyondwords_post_metadata filter. * @since 7.0.0 Renamed to get_tags(), returning a flat array of term names. * * @return string[] Term names from every taxonomy of the post type. */ public static function get_tags( int $post_id ): array { $taxonomies = get_object_taxonomies( (string) get_post_type( $post_id ) ); $tags = []; foreach ( $taxonomies as $taxonomy ) { $terms = get_the_terms( $post_id, $taxonomy ); if ( ! empty( $terms ) && ! is_wp_error( $terms ) ) { $tags = array_merge( $tags, wp_list_pluck( $terms, 'name' ) ); } } // Core stores term names HTML-encoded, so "R&D" would reach the API as "R&D". $tags = array_map( fn( $tag ) => wp_specialchars_decode( $tag, ENT_QUOTES ), $tags ); // Terms in different taxonomies can share a name, and the API wants each tag once. return array_values( array_unique( $tags ) ); } /** * Get author name for a post. * * @since 3.10.4 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods. * * @param int $post_id Post ID. */ public static function get_author_name( int $post_id ): string { $author_id = get_post_field( 'post_author', $post_id ); $name = get_the_author_meta( 'display_name', $author_id ); // Core stores display names HTML-encoded, so "Smith & Sons" would reach the API as "Smith & Sons". return wp_specialchars_decode( $name, ENT_QUOTES ); } }