* @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 );
}
}