Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.25% covered (success)
81.25%
130 / 160
64.29% covered (warning)
64.29%
9 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
PostContentUtils
81.25% covered (success)
81.25%
130 / 160
64.29% covered (warning)
64.29%
9 / 14
64.83
0.00% covered (danger)
0.00%
0 / 1
 getContentBody
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 getPostBody
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
3.10
 getPostSummaryWrapperFormat
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getPostSummary
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 getSegments
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
20
 getContentWithoutExcludedBlocks
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 getAudioEnabledBlocks
83.33% covered (success)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
5.12
 getContentParams
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
7
 getMetadata
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getAllTaxonomiesAndTerms
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 getAuthorName
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addMarkerAttribute
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 addMarkerAttributeWithHTMLTagProcessor
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 addMarkerAttributeWithDOMDocument
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
4
1<?php
2
3declare(strict_types=1);
4
5namespace Beyondwords\Wordpress\Component\Post;
6
7/**
8 * BeyondWords Post Content Utilities.
9 *
10 * @package    Beyondwords
11 * @subpackage Beyondwords/includes
12 * @author     Stuart McAlpine <stu@beyondwords.io>
13 * @since      3.5.0
14 */
15class PostContentUtils
16{
17    public const DATE_FORMAT = 'Y-m-d\TH:i:s\Z';
18
19    /**
20     * Get the content "body" param for the audio, ready to be sent to the
21     * BeyondWords API.
22     *
23     * From API version 1.1 the "summary" param is going to be used differently,
24     * so for WordPress we now prepend the WordPress excerpt to the "body" param.
25     *
26     * @param int|\WP_Post $post The WordPress post ID, or post object.
27     *
28     * @since 4.6.0
29     *
30     * @return string The content body param.
31     */
32    public static function getContentBody(int|\WP_Post $post): string|null
33    {
34        $post = get_post($post);
35
36        if (!($post instanceof \WP_Post)) {
37            throw new \Exception(esc_html__('Post Not Found', 'speechkit'));
38        }
39
40        $summary = PostContentUtils::getPostSummary($post);
41        $body    = PostContentUtils::getPostBody($post);
42
43        if ($summary) {
44            $format = PostContentUtils::getPostSummaryWrapperFormat($post);
45
46            $body = sprintf($format, $summary) . $body;
47        }
48
49        return $body;
50    }
51
52    /**
53     * Get the post body for the audio content.
54     *
55     * @since 3.0.0
56     * @since 3.5.0 Moved from Core\Utils to Component\Post\PostUtils
57     * @since 3.8.0 Exclude Gutenberg blocks with attribute { beyondwordsAudio: false }
58     * @since 4.0.0 Renamed from PostContentUtils::getSourceTextForAudio() to PostContentUtils::getBody()
59     * @since 4.6.0 Renamed from PostContentUtils::getBody() to PostContentUtils::getPostBody()
60     * @since 4.7.0 Remove wpautop filter for block editor API requests.
61     * @since 5.0.0 Remove SpeechKit-Start shortcode.
62     * @since 5.0.0 Remove beyondwords_content filter.
63     *
64     * @param int|\WP_Post $post The WordPress post ID, or post object.
65     *
66     * @return string The body (the processed $post->post_content).
67     */
68    public static function getPostBody(int|\WP_Post $post): string|null
69    {
70        $post = get_post($post);
71
72        if (!($post instanceof \WP_Post)) {
73            throw new \Exception(esc_html__('Post Not Found', 'speechkit'));
74        }
75
76        $content = PostContentUtils::getContentWithoutExcludedBlocks($post);
77
78        if (has_blocks($post)) {
79            // wpautop breaks our HTML markup when block editor paragraphs are empty
80            remove_filter('the_content', 'wpautop');
81
82            // But we still want to remove empty lines
83            $content = preg_replace('/^\h*\v+/m', '', $content);
84        }
85
86        // Apply the_content filters to handle shortcodes etc
87        $content = apply_filters('the_content', $content);
88
89        // Trim to remove trailing newlines â€“ common for WordPress content
90        return trim($content);
91    }
92
93    /**
94     * Get the post summary wrapper format.
95     *
96     * This is a <div> with optional attributes depending on the BeyondWords
97     * data of the post.
98     *
99     * @param int|\WP_Post $post The WordPress post ID, or post object.
100     *
101     * @since 4.6.0
102     *
103     * @return string The summary wrapper <div>.
104     */
105    public static function getPostSummaryWrapperFormat(int|\WP_Post $post): string
106    {
107        $post = get_post($post);
108
109        if (!($post instanceof \WP_Post)) {
110            throw new \Exception(esc_html__('Post Not Found', 'speechkit'));
111        }
112
113        $summaryVoiceId = intval(get_post_meta($post->ID, 'beyondwords_summary_voice_id', true));
114
115        if ($summaryVoiceId > 0) {
116            return '<div data-beyondwords-summary="true" data-beyondwords-voice-id="' . $summaryVoiceId . '">%s</div>';
117        }
118
119        return '<div data-beyondwords-summary="true">%s</div>';
120    }
121
122    /**
123     * Get the post summary for the audio content.
124     *
125     * @param int|\WP_Post $post The WordPress post ID, or post object.
126     *
127     * @since 4.0.0
128     * @since 4.6.0 Renamed from PostContentUtils::getSummary() to PostContentUtils::getPostSummary()
129     *
130     * @return string The summary.
131     */
132    public static function getPostSummary(int|\WP_Post $post): string|null
133    {
134        $post = get_post($post);
135
136        if (!($post instanceof \WP_Post)) {
137            throw new \Exception(esc_html__('Post Not Found', 'speechkit'));
138        }
139
140        $summary = null;
141
142        // Optionally send the excerpt to the REST API, if the plugin setting has been checked
143        $prependExcerpt = get_option('beyondwords_prepend_excerpt');
144
145        if ($prependExcerpt && has_excerpt($post)) {
146            // Escape characters
147            $summary = htmlentities($post->post_excerpt, ENT_QUOTES | ENT_XHTML);
148            // Apply WordPress filters
149            $summary = apply_filters('get_the_excerpt', $summary);
150            // Convert line breaks into paragraphs
151            $summary = trim(wpautop($summary));
152        }
153
154        return $summary;
155    }
156
157    /**
158     * Get the segments for the audio content, ready to be sent to the BeyondWords API.
159     *
160     * @codeCoverageIgnore
161     * THIS METHOD IS CURRENTLY NOT IN USE. Segments cannot currently include HTML
162     * formatting tags such as <strong> and <em> so we do not pass segments, we pass
163     * a HTML string as the body param instead.
164     *
165     * @param int|\WP_Post $post The WordPress post ID, or post object.
166     *
167     * @since 4.0.0
168     */
169    public static function getSegments(int|\WP_Post $post): array
170    {
171        if (! has_blocks($post)) {
172            return [];
173        }
174
175        $titleSegment = (object) [
176            'section' => 'title',
177            'text'    => get_the_title($post),
178        ];
179
180        $summarySegment = (object) [
181            'section' => 'summary',
182            'text'    => PostContentUtils::getPostSummary($post),
183        ];
184
185        $blocks = PostContentUtils::getAudioEnabledBlocks($post);
186
187        $bodySegments = array_map(function ($block) {
188            $marker = null;
189
190            if (isset($block['attrs']) && isset($block['attrs']['beyondwordsMarker'])) {
191                $marker = $block['attrs']['beyondwordsMarker'];
192            }
193
194            return (object) [
195                'section' => 'body',
196                'marker'  => $marker,
197                'text'    => trim(render_block($block)),
198            ];
199        }, $blocks);
200
201        // Merge title, summary and body segments
202        $segments = array_values(array_merge([$titleSegment], [$summarySegment], $bodySegments));
203
204        // Remove any segments with empty text
205        $segments = array_values(array_filter($segments, fn($segment) => ! empty($segment::text)));
206
207        return $segments;
208    }
209
210    /**
211     * Get the post content without blocks which have been filtered.
212     *
213     * We have added buttons into the Gutenberg editor to optionally exclude selected
214     * blocks from the source text for audio.
215     *
216     * This method filters all blocks, removing any which have been excluded.
217     *
218     * @param int|\WP_Post $post The WordPress post ID, or post object.
219     *
220     * @since 3.8.0
221     * @since 4.0.0 Replace for loop with array_reduce
222     *
223     * @return string The post body without excluded blocks.
224     */
225    public static function getContentWithoutExcludedBlocks(int|\WP_Post $post): string
226    {
227        if (! has_blocks($post)) {
228            return trim($post->post_content);
229        }
230
231        $blocks = parse_blocks($post->post_content);
232        $output = '';
233
234        $blocks = PostContentUtils::getAudioEnabledBlocks($post);
235
236        foreach ($blocks as $block) {
237            $marker = $block['attrs']['beyondwordsMarker'] ?? '';
238
239            $output .= PostContentUtils::addMarkerAttribute(
240                render_block($block),
241                $marker
242            );
243        }
244
245        return $output;
246    }
247
248    /**
249     * Get audio-enabled blocks.
250     *
251     * @param int|\WP_Post $post The WordPress post ID, or post object.
252     *
253     * @since 4.0.0
254     * @since 5.0.0 Remove beyondwords_post_audio_enabled_blocks filter.
255     *
256     * @return array The blocks.
257     */
258    public static function getAudioEnabledBlocks(int|\WP_Post $post): array
259    {
260        $post = get_post($post);
261
262        if (! ($post instanceof \WP_Post)) {
263            return [];
264        }
265
266        if (! has_blocks($post)) {
267            return [];
268        }
269
270        $allBlocks = parse_blocks($post->post_content);
271
272        return array_filter($allBlocks, function ($block) {
273            $enabled = true;
274
275            if (is_array($block['attrs']) && isset($block['attrs']['beyondwordsAudio'])) {
276                $enabled = (bool) $block['attrs']['beyondwordsAudio'];
277            }
278
279            return $enabled;
280        });
281    }
282
283    /**
284     * Get the body param we pass to the API.
285     *
286     * @since 3.0.0  Introduced as getBodyJson.
287     * @since 3.3.0  Added metadata to aid custom playlist generation.
288     * @since 3.5.0  Moved from Core\Utils to Component\Post\PostUtils.
289     * @since 3.10.4 Rename `published_at` API param to `publish_date`.
290     * @since 4.0.0  Use new API params.
291     * @since 4.0.3  Ensure `image_url` is always a string.
292     * @since 4.3.0  Rename from getBodyJson to getContentParams.
293     * @since 4.6.0  Remove summary param & prepend body with summary.
294     * @since 5.0.0  Remove beyondwords_body_params filter.
295     * @since 6.0.0  Cast return value to string.
296     *
297     * @static
298     * @param int $postId WordPress Post ID.
299     *
300     * @return string JSON endoded params.
301     **/
302    public static function getContentParams(int $postId): array|string
303    {
304        $body = [
305            'type'         => 'auto_segment',
306            'title'        => get_the_title($postId),
307            'body'         => PostContentUtils::getContentBody($postId),
308            'source_url'   => get_the_permalink($postId),
309            'source_id'    => strval($postId),
310            'author'       => PostContentUtils::getAuthorName($postId),
311            'image_url'    => strval(wp_get_original_image_url(get_post_thumbnail_id($postId))),
312            'metadata'     => PostContentUtils::getMetadata($postId),
313            'publish_date' => get_post_time(PostContentUtils::DATE_FORMAT, true, $postId),
314        ];
315
316        $status = get_post_status($postId);
317
318        /*
319         * If the post status is draft/pending then we explicity send
320         * { published: false } to the BeyondWords API, to prevent the
321         * generated audio from being published in playlists.
322         *
323         * We also omit { publish_date } because get_post_time() returns `false`
324         * for posts which are "Pending Review".
325         */
326        if (in_array($status, ['draft', 'pending'])) {
327            $body['published'] = false;
328            unset($body['publish_date']);
329        } elseif (get_option('beyondwords_project_auto_publish_enabled')) {
330            $body['published'] = true;
331        }
332
333        $languageCode = get_post_meta($postId, 'beyondwords_language_code', true);
334
335        if ($languageCode) {
336            $body['language'] = $languageCode;
337        }
338
339        $bodyVoiceId = intval(get_post_meta($postId, 'beyondwords_body_voice_id', true));
340
341        if ($bodyVoiceId > 0) {
342            $body['body_voice_id'] = $bodyVoiceId;
343        }
344
345        $titleVoiceId = intval(get_post_meta($postId, 'beyondwords_title_voice_id', true));
346
347        if ($titleVoiceId > 0) {
348            $body['title_voice_id'] = $titleVoiceId;
349        }
350
351        $summaryVoiceId = intval(get_post_meta($postId, 'beyondwords_summary_voice_id', true));
352
353        if ($summaryVoiceId > 0) {
354            $body['summary_voice_id'] = $summaryVoiceId;
355        }
356
357        /**
358         * Filters the params we send to the BeyondWords API 'content' endpoint.
359         *
360         * @since 4.0.0 Introduced as beyondwords_body_params
361         * @since 4.3.0 Renamed from beyondwords_body_params to beyondwords_content_params
362         *
363         * @param array $body   The params we send to the BeyondWords API.
364         * @param array $postId WordPress post ID.
365         */
366        $body = apply_filters('beyondwords_content_params', $body, $postId);
367
368        return (string) wp_json_encode($body);
369    }
370
371    /**
372     * Get the post metadata to send with BeyondWords API requests.
373     *
374     * The metadata key is defined by the BeyondWords API as "A custom object
375     * for storing meta information".
376     *
377     * The metadata values are used to create filters for playlists in the
378     * BeyondWords dashboard.
379     *
380     * We currently only include taxonomies by default, and the output of this
381     * method can be filtered using the `beyondwords_post_metadata` filter.
382     *
383     * @since 3.3.0
384     * @since 3.5.0 Moved from Core\Utils to Component\Post\PostUtils.
385     * @since 5.0.0 Remove beyondwords_post_metadata filter.
386     *
387     * @param int $postId Post ID.
388     *
389     * @return object The metadata object (empty if no metadata).
390     */
391    public static function getMetadata(int $postId): array|object
392    {
393        $metadata = new \stdClass();
394
395        $taxonomy = PostContentUtils::getAllTaxonomiesAndTerms($postId);
396
397        if (count((array)$taxonomy)) {
398            $metadata->taxonomy = $taxonomy;
399        }
400
401        return $metadata;
402    }
403
404    /**
405     * Get all taxonomies, and their selected terms, for a post.
406     *
407     * Returns an associative array of taxonomy names and terms.
408     *
409     * For example:
410     *
411     * array(
412     *     "categories" => array("Category 1"),
413     *     "post_tag" => array("Tag 1", "Tag 2", "Tag 3"),
414     * )
415     *
416     * @since 3.3.0
417     * @since 3.5.0 Moved from Core\Utils to Component\Post\PostUtils
418     *
419     * @param int $postId Post ID.
420     *
421     * @return object The taxonomies object (empty if no taxonomies).
422     */
423    public static function getAllTaxonomiesAndTerms(int $postId): array|object
424    {
425        $postType = get_post_type($postId);
426
427        $postTypeTaxonomies = get_object_taxonomies($postType);
428
429        $taxonomies = new \stdClass();
430
431        foreach ($postTypeTaxonomies as $postTypeTaxonomy) {
432            $terms = get_the_terms($postId, $postTypeTaxonomy);
433
434            if (! empty($terms) && ! is_wp_error($terms)) {
435                $taxonomies->{(string)$postTypeTaxonomy} = wp_list_pluck($terms, 'name');
436            }
437        }
438
439        return $taxonomies;
440    }
441
442    /**
443     * Get author name for a post.
444     *
445     * @since 3.10.4
446     *
447     * @param int $postId Post ID.
448     */
449    public static function getAuthorName(int $postId): string
450    {
451        $authorId = get_post_field('post_author', $postId);
452
453        return get_the_author_meta('display_name', $authorId);
454    }
455
456    /**
457     * Add data-beyondwords-marker attribute to the root elements in a HTML
458     * string (typically the rendered HTML of a single block).
459     *
460     * Checks to see whether we can use WP_HTML_Tag_Processor, or whether we
461     * fall back to using DOMDocument to add the marker.
462     *
463     * @since 4.2.2
464     *
465     * @param string  $html   HTML.
466     * @param string  $marker Marker UUID.
467     *
468     * @return string HTML.
469     */
470    public static function addMarkerAttribute(string $html, string $marker): string
471    {
472        if (! $marker) {
473            return $html;
474        }
475
476        // Prefer WP_HTML_Tag_Processor, introduced in WordPress 6.2
477        if (class_exists('WP_HTML_Tag_Processor')) {
478            return PostContentUtils::addMarkerAttributeWithHTMLTagProcessor($html, $marker);
479        } else {
480            return PostContentUtils::addMarkerAttributeWithDOMDocument($html, $marker);
481        }
482    }
483
484    /**
485     * Add data-beyondwords-marker attribute to the root elements in a HTML
486     * string using WP_HTML_Tag_Processor.
487     *
488     * @since 4.0.0
489     * @since 4.2.2 Moved from src/Component/Post/BlockAttributes/BlockAttributes.php
490     *              to src/Component/Post/PostContentUtils.php
491     * @since 4.7.0 Prevent empty data-beyondwords-marker attributes.
492     *
493     * @param string  $html   HTML.
494     * @param string  $marker Marker UUID.
495     *
496     * @return string HTML.
497     */
498    public static function addMarkerAttributeWithHTMLTagProcessor(string $html, string $marker): string
499    {
500        if (! $marker) {
501            return $html;
502        }
503
504        // https://github.com/WordPress/gutenberg/pull/42485
505        $tags = new \WP_HTML_Tag_Processor($html);
506
507        if ($tags->next_tag()) {
508            $tags->set_attribute('data-beyondwords-marker', $marker);
509        }
510
511        return strval($tags);
512    }
513
514    /**
515     * Add data-beyondwords-marker attribute to the root elements in a HTML
516     * string using DOMDocument.
517     *
518     * This is a fallback, since WP_HTML_Tag_Processor was only shipped with
519     * WordPress 6.2 on 19 April 2023.
520     *
521     * https://make.wordpress.org/core/2022/10/13/whats-new-in-gutenberg-14-3-12-october/
522     *
523     * Note: It is not ideal to do all the $bodyElement/$fullHtml processing
524     * in this method, but without it DOMDocument does not work as expected if
525     * there is more than 1 root element. The approach here has been taken from
526     * some historic Gutenberg code before they implemented WP_HTML_Tag_Processor:
527     *
528     * https://github.com/WordPress/gutenberg/blob/6671cef1179412a2bbd4969cbbc82705c7f69bac/lib/block-supports/index.php
529     *
530     * @since 4.0.0
531     * @since 4.2.2 Moved from src/Component/Post/BlockAttributes/BlockAttributes.php
532     *              to src/Component/Post/PostContentUtils.php
533     * @since 4.7.0 Prevent empty data-beyondwords-marker attributes.
534     *
535     * @param string  $html   HTML.
536     * @param string  $marker Marker UUID.
537     *
538     * @return string HTML.
539     */
540    public static function addMarkerAttributeWithDOMDocument(string $html, string $marker): string
541    {
542        if (! $marker) {
543            return $html;
544        }
545
546        $dom = new \DOMDocument('1.0', 'utf-8');
547
548        $wrappedHtml =
549            '<html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"></head><body>'
550            . $html
551            . '</body></html>';
552
553        $success = $dom->loadHTML($wrappedHtml, LIBXML_HTML_NODEFDTD | LIBXML_COMPACT);
554
555        if (! $success) {
556            return $html;
557        }
558
559        // Structure is like `<html><head/><body/></html>`, so body is the `lastChild` of our document.
560        $bodyElement = $dom->documentElement->lastChild;
561
562        $xpath     = new \DOMXPath($dom);
563        $blockRoot = $xpath->query('./*', $bodyElement)[0];
564
565        if (empty($blockRoot)) {
566            return $html;
567        }
568
569        $blockRoot->setAttribute('data-beyondwords-marker', $marker);
570
571        // Avoid using `$dom->saveHtml( $node )` because the node results may not produce consistent
572        // whitespace. Saving the root HTML `$dom->saveHtml()` prevents this behavior.
573        $fullHtml = $dom->saveHtml();
574
575        // Find the <body> open/close tags. The open tag needs to be adjusted so we get inside the tag
576        // and not the tag itself.
577        $start = strpos($fullHtml, '<body>', 0) + strlen('<body>');
578        $end   = strpos($fullHtml, '</body>', $start);
579
580        return trim(substr($fullHtml, $start, $end - $start));
581    }
582}