* @since 3.5.0 */ class PostContentUtils { public const DATE_FORMAT = 'Y-m-d\TH:i:s\Z'; /** * Get the source text for the audio, ready to be sent to the BeyondWords API. * * @deprecated 4.0.0 Renamed to PostContentUtils::getBody() * * @param int|WP_Post $post The WordPress post ID, or post object. * * @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 PostContentUtils::getSourceTextForAudio() to PostContentUtils::getBody() * * @return string The body (the processed $post->post_content). */ public static function getSourceTextForAudio($post) { _doing_it_wrong( 'PostContentUtils::getBody', 'BeyondWords PostContentUtils::getSourceTextForAudio() has been renamed to PostContentUtils::getBody()', '4.0.0' ); return PostContentUtils::getBody($post); } /** * Get the body for the audio, ready to be sent to the BeyondWords API. * * The following rules are applied: * * Main body content entered in WordPress * + Optionally filtered using [SpeechKit-Start]/[SpeechKit-Stop] "shortcodes" * + With registered content filters FROM OTHER PLUGINS applied * + Optionally prepended with the Post excerpt * + Optionally filtered using the beyondwords_content filter * * @SuppressWarnings(PHPMD.LongVariable) * * @param int|WP_Post $post The WordPress post ID, or post object. * * @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 PostContentUtils::getSourceTextForAudio() to PostContentUtils::getBody() * * @return string The body (the processed $post->post_content). */ public static function getBody($post) { global $beyondwords_wordpress_plugin; $post = get_post($post); if (!($post instanceof \WP_Post)) { throw new \Exception('Post Not Found'); } $content = PostContentUtils::getContentWithoutExcludedBlocks($post); // If SpeechKit-Start/Stop tags are present then use the content within them // @deprecated v3.0.0: publishers should use the beyondwords_content filter instead. $regex = '/\[SpeechKit-Start\](.*?)\[SpeechKit-Stop\]/s'; if (preg_match_all($regex, $content, $match, PREG_PATTERN_ORDER) > 0) { $content = implode(' ', $match[1]); } // Temporarily remove our Player filter, to exclude the player
if ($beyondwords_wordpress_plugin && isset($beyondwords_wordpress_plugin->player)) { remove_filter('the_content', array($beyondwords_wordpress_plugin->player, 'autoPrependPlayer')); } // Apply other standard WordPress filters to handle shortcodes etc $content = apply_filters('the_content', $content); // Add our Player filter back in again if ($beyondwords_wordpress_plugin && isset($beyondwords_wordpress_plugin->player)) { add_filter('the_content', array($beyondwords_wordpress_plugin->player, 'autoPrependPlayer')); } // TODO maybe we need to do something like this to deal with Shortcodes a little better? // $content = wp_strip_all_tags(apply_filters('the_content', $content)); // Trim to remove trailing newlines – common for WordPress content $content = trim($content); /** * Filters the content body we send for audio processing. * * @since 4.0.0 * * @param string $content The post content. * @param int $postId The post ID. */ $content = apply_filters('beyondwords_content', $content, $post->ID); return $content; } /** * Get the summary for the audio content, ready to be sent to the BeyondWords API. * * @param int|WP_Post $post The WordPress post ID, or post object. * * @since 4.0.0 * * @return string The summary. */ public static function getSummary($post) { $post = get_post($post); if (!($post instanceof \WP_Post)) { throw new \Exception('Post Not Found'); } $summary = null; // Optionally send the excerpt to the REST API, if the plugin setting has been checked $prependExcerpt = get_option('beyondwords_prepend_excerpt'); if ($prependExcerpt && has_excerpt($post)) { // Escape characters $summary = htmlentities($post->post_excerpt, ENT_QUOTES | ENT_XHTML); // Apply WordPress filters $summary = apply_filters('get_the_excerpt', $summary); // Convert line breaks into paragraphs $summary = trim(wpautop($summary)); } return $summary; } /** * Get the segments for the audio content, ready to be sent to the BeyondWords API. * * THIS METHOD IS CURRENTLY NOT IN USE. Segments cannot currently include HTML * formatting tags such as and so we do not pass segments, we pass * a HTML string as the body param instead. * * @param int|WP_Post $post The WordPress post ID, or post object. * * @since 4.0.0 * * @return array|null The segments. */ public static function getSegments($post) { if (! has_blocks($post)) { return null; } $titleSegment = (object) [ 'section' => 'title', 'text' => get_the_title($post), ]; $summarySegment = (object) [ 'section' => 'summary', 'text' => PostContentUtils::getSummary($post), ]; $blocks = PostContentUtils::getAudioEnabledBlocks($post); $bodySegments = array_map(function ($block) { $marker = null; if (isset($block['attrs']) && isset($block['attrs']['beyondwordsMarker'])) { $marker = $block['attrs']['beyondwordsMarker']; } return (object) [ 'section' => 'body', 'marker' => $marker, 'text' => trim(render_block($block)), ]; }, $blocks); // Merge title, summary and body segments $segments = array_values(array_merge([$titleSegment], [$summarySegment], $bodySegments)); // TODO Consider removing this when API can handle it // Remove any segments with empty text $segments = array_values(array_filter($segments, function ($segment) { return (! empty($segment->text)); })); return $segments; } /** * Get the post content without blocks which have been filtered. * * We have added buttons into the Gutenberg editor to optionally exclude selected * blocks from the source text for audio. * * This method filters all blocks, removing any which have been excluded. * * @param int|WP_Post $post The WordPress post ID, or post object. * * @since 3.8.0 * @since 4.0.0 Replace for loop with array_reduce * * @return string The post body without excluded blocks. */ public static function getContentWithoutExcludedBlocks($post) { if (! has_blocks($post)) { return trim($post->post_content); } $blocks = parse_blocks($post->post_content); $output = ''; $blocks = PostContentUtils::getAudioEnabledBlocks($post); foreach ($blocks as $block) { $marker = $block['attrs']['beyondwordsMarker'] ?? ''; $output .= PostContentUtils::addMarkerAttribute( render_block($block), $marker ); } return $output; } /** * Get audio-enabled blocks. * * @param int|WP_Post $post The WordPress post ID, or post object. * * @since 4.0.0 * * @return array The blocks. */ public static function getAudioEnabledBlocks($post) { $post = get_post($post); if (! ($post instanceof \WP_Post)) { return []; } if (! has_blocks($post)) { return []; } $allBlocks = parse_blocks($post->post_content); $blocks = array_filter($allBlocks, function ($block) { $enabled = true; if (is_array($block['attrs']) && isset($block['attrs']['beyondwordsAudio'])) { $enabled = (bool) $block['attrs']['beyondwordsAudio']; } return $enabled; }); /** * Filters the audio-enabled blocks for a post. * * @since 4.0.0 * * @param array $blocks The audio-enabled post blocks. * @param array $allBlocks All post blocks including those with audio disabled. * @param int $postId The post ID. */ $blocks = apply_filters('beyondwords_post_audio_enabled_blocks', $blocks, $allBlocks, $post->ID); return $blocks; } /** * Get the body param we pass to the API. * * @since 3.0.0 * @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. * * @static * @param int $postId WordPress Post ID. * * @return Response **/ public static function getBodyJson($postId) { $body = [ 'type' => 'auto_segment', 'title' => get_the_title($postId), 'summary' => PostContentUtils::getSummary($postId), 'body' => PostContentUtils::getBody($postId), 'source_url' => get_the_permalink($postId), 'source_id' => strval($postId), 'author' => PostContentUtils::getAuthorName($postId), 'image_url' => strval(wp_get_original_image_url(get_post_thumbnail_id($postId))), 'metadata' => PostContentUtils::getMetadata($postId), 'published' => true, 'publish_date' => get_post_time(PostContentUtils::DATE_FORMAT, true, $postId), ]; $status = get_post_status($postId); /* * If the post status is "pending" then we send { published: false } to * the BeyondWords API, to prevent the generated audio from being * published in playlists. * * We also omit { publish_date } because get_post_time() returns `false` * for posts which are "Pending Review". */ if ($status === 'pending') { $body['published'] = false; unset($body['publish_date']); } $bodyVoiceId = intval(get_post_meta($postId, 'beyondwords_body_voice_id', true)); if ($bodyVoiceId > 0) { $body['body_voice_id'] = $bodyVoiceId; } $titleVoiceId = intval(get_post_meta($postId, 'beyondwords_title_voice_id', true)); if ($titleVoiceId > 0) { $body['title_voice_id'] = $titleVoiceId; } $summaryVoiceId = intval(get_post_meta($postId, 'beyondwords_summary_voice_id', true)); if ($summaryVoiceId > 0) { $body['summary_voice_id'] = $summaryVoiceId; } /** * Filters the body params we send to the BeyondWords API when * processing audio. * * @since 4.0.0 * * @param array $body The params we send to the BeyondWords API. * @param array $postId WordPress post ID. */ $body = apply_filters('beyondwords_body_params', $body, $postId); return wp_json_encode($body); } /** * Get the post metadata to send with BeyondWords API requests. * * The metadata key is defined by the BeyondWords API as "A custom object * for storing meta information". * * The metadata values are used to create filters for playlists in the * BeyondWords dashboard. * * We currently only include taxonomies by default, and the output of this * method can be filtered using the `beyondwords_post_metadata` filter. * * @since 3.3.0 * @since 3.5.0 Moved from Core\Utils to Component\Post\PostUtils * * @param int $postId Post ID. * * @return array */ public static function getMetadata($postId) { $metadata = new \stdClass(); $taxonomy = PostContentUtils::getAllTaxonomiesAndTerms($postId); if (count((array)$taxonomy)) { $metadata->taxonomy = $taxonomy; } /** * Filters the post metadata sent to the BeyondWords API. * * @since 3.3.0 * * @param object $metadata Post metadata. Defaults to the taxonomies and terms assigned to the post. * @param int $postId Post ID. */ $metadata = apply_filters('beyondwords_post_metadata', $metadata, $postId); return $metadata; } /** * Get all taxonomies, and their selected terms, for a post. * * Returns an associative array of taxonomy names and terms. * * For example: * * array( * "categories" => array("Category 1"), * "post_tag" => array("Tag 1", "Tag 2", "Tag 3"), * ) * * @since 3.3.0 * @since 3.5.0 Moved from Core\Utils to Component\Post\PostUtils * * @param int $postId Post ID. * * @return array */ public static function getAllTaxonomiesAndTerms($postId) { $postType = get_post_type($postId); $postTypeTaxonomies = get_object_taxonomies($postType); $taxonomies = new \stdClass(); foreach ($postTypeTaxonomies as $postTypeTaxonomy) { $terms = get_the_terms($postId, $postTypeTaxonomy); if (! empty($terms) && ! is_wp_error($terms)) { $taxonomies->{(string)$postTypeTaxonomy} = wp_list_pluck($terms, 'name'); } } return $taxonomies; } /** * Get author name for a post. * * @since 3.10.4 * * @param int $postId Post ID. * * @return string */ public static function getAuthorName($postId) { $authorId = get_post_field('post_author', $postId); return get_the_author_meta('display_name', $authorId); } /** * Add data-beyondwords-marker attribute to the root elements in a HTML * string (typically the rendered HTML of a single block). * * Checks to see whether we can use WP_HTML_Tag_Processor, or whether we * fall back to using DOMDocument to add the marker. * * @since 4.2.2 * * @param string $html HTML. * @param string $marker Marker UUID. * * @return string HTML. */ public static function addMarkerAttribute($html, $marker) { if (! $marker) { return $html; } // Prefer WP_HTML_Tag_Processor, introduced in WordPress 6.2 if (class_exists('WP_HTML_Tag_Processor')) { return PostContentUtils::addMarkerAttributeWithHTMLTagProcessor($html, $marker); } else { return PostContentUtils::addMarkerAttributeWithDOMDocument($html, $marker); } } /** * Add data-beyondwords-marker attribute to the root elements in a HTML * string using WP_HTML_Tag_Processor. * * @since 4.0.0 * @since 4.2.2 Moved from src/Component/Post/BlockAttributes/BlockAttributes.php * to src/Component/Post/PostContentUtils.php * * @param string $html HTML. * @param string $marker Marker UUID. * * @return string HTML. */ public static function addMarkerAttributeWithHTMLTagProcessor($html, $marker) { // https://github.com/WordPress/gutenberg/pull/42485 $tags = new \WP_HTML_Tag_Processor($html); if ($tags->next_tag()) { $tags->set_attribute('data-beyondwords-marker', $marker); } return strval($tags); } /** * Add data-beyondwords-marker attribute to the root elements in a HTML * string using DOMDocument. * * This is a fallback, since WP_HTML_Tag_Processor was only shipped with * WordPress 6.2 on 19 April 2023. * * https://make.wordpress.org/core/2022/10/13/whats-new-in-gutenberg-14-3-12-october/ * * Note: It is not ideal to do all the $bodyElement/$fullHtml processing * in this method, but without it DOMDocument does not work as expected if * there is more than 1 root element. The approach here has been taken from * some historic Gutenberg code before they implemented WP_HTML_Tag_Processor: * * https://github.com/WordPress/gutenberg/blob/6671cef1179412a2bbd4969cbbc82705c7f69bac/lib/block-supports/index.php * * @since 4.0.0 * @since 4.2.2 Moved from src/Component/Post/BlockAttributes/BlockAttributes.php * to src/Component/Post/PostContentUtils.php * * @param string $html HTML. * @param string $marker Marker UUID. * * @return string HTML. */ public static function addMarkerAttributeWithDOMDocument($html, $marker) { $dom = new \DOMDocument('1.0', 'utf-8'); $wrappedHtml = '' . $html . ''; $success = $dom->loadHTML($wrappedHtml, LIBXML_HTML_NODEFDTD | LIBXML_COMPACT); if (! $success) { return $html; } // Structure is like ``, so body is the `lastChild` of our document. $bodyElement = $dom->documentElement->lastChild; $xpath = new \DOMXPath($dom); $blockRoot = $xpath->query('./*', $bodyElement)[0]; if (empty($blockRoot)) { return $html; } $blockRoot->setAttribute('data-beyondwords-marker', $marker); // Avoid using `$dom->saveHtml( $node )` because the node results may not produce consistent // whitespace. Saving the root HTML `$dom->saveHtml()` prevents this behavior. $fullHtml = $dom->saveHtml(); // Find the open/close tags. The open tag needs to be adjusted so we get inside the tag // and not the tag itself. $start = strpos($fullHtml, '', 0) + strlen(''); $end = strpos($fullHtml, '', $start); return trim(substr($fullHtml, $start, $end - $start)); } }