Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.08% covered (success)
96.08%
98 / 102
80.00% covered (success)
80.00%
8 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
PostContentUtils
96.04% covered (success)
96.04%
97 / 101
80.00% covered (success)
80.00%
8 / 10
35
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
 getContentWithoutExcludedBlocks
100.00% covered (success)
100.00%
8 / 8
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
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 */
15defined('ABSPATH') || exit;
16
17class PostContentUtils
18{
19    public const DATE_FORMAT = 'Y-m-d\TH:i:s\Z';
20
21    /**
22     * Get the content "body" param for the audio, ready to be sent to the
23     * BeyondWords API.
24     *
25     * From API version 1.1 the "summary" param is going to be used differently,
26     * so for WordPress we now prepend the WordPress excerpt to the "body" param.
27     *
28     * @param int|\WP_Post $post The WordPress post ID, or post object.
29     *
30     * @since 4.6.0
31     *
32     * @return string The content body param.
33     */
34    public static function getContentBody(int|\WP_Post $post): string|null
35    {
36        $post = get_post($post);
37
38        if (!($post instanceof \WP_Post)) {
39            throw new \Exception(esc_html__('Post Not Found', 'speechkit'));
40        }
41
42        $summary = PostContentUtils::getPostSummary($post);
43        $body    = PostContentUtils::getPostBody($post);
44
45        if ($summary) {
46            $format = PostContentUtils::getPostSummaryWrapperFormat($post);
47
48            $body = sprintf($format, $summary) . $body;
49        }
50
51        return $body;
52    }
53
54    /**
55     * Get the post body for the audio content.
56     *
57     * @since 3.0.0
58     * @since 3.5.0 Moved from Core\Utils to Component\Post\PostUtils
59     * @since 3.8.0 Exclude Gutenberg blocks with attribute { beyondwordsAudio: false }
60     * @since 4.0.0 Renamed from PostContentUtils::getSourceTextForAudio() to PostContentUtils::getBody()
61     * @since 4.6.0 Renamed from PostContentUtils::getBody() to PostContentUtils::getPostBody()
62     * @since 4.7.0 Remove wpautop filter for block editor API requests.
63     * @since 5.0.0 Remove SpeechKit-Start shortcode.
64     * @since 5.0.0 Remove beyondwords_content filter.
65     *
66     * @param int|\WP_Post $post The WordPress post ID, or post object.
67     *
68     * @return string The body (the processed $post->post_content).
69     */
70    public static function getPostBody(int|\WP_Post $post): string|null
71    {
72        $post = get_post($post);
73
74        if (!($post instanceof \WP_Post)) {
75            throw new \Exception(esc_html__('Post Not Found', 'speechkit'));
76        }
77
78        $content = PostContentUtils::getContentWithoutExcludedBlocks($post);
79
80        if (has_blocks($post)) {
81            // wpautop breaks our HTML markup when block editor paragraphs are empty
82            remove_filter('the_content', 'wpautop');
83
84            // But we still want to remove empty lines
85            $content = preg_replace('/^\h*\v+/m', '', $content);
86        }
87
88        // Apply the_content filters to handle shortcodes etc
89        // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Applying core WordPress filter
90        $content = apply_filters('the_content', $content);
91
92        // Trim to remove trailing newlines â€“ common for WordPress content
93        return trim($content);
94    }
95
96    /**
97     * Get the post summary wrapper format.
98     *
99     * This is a <div> with optional attributes depending on the BeyondWords
100     * data of the post.
101     *
102     * @param int|\WP_Post $post The WordPress post ID, or post object.
103     *
104     * @since 4.6.0
105     *
106     * @return string The summary wrapper <div>.
107     */
108    public static function getPostSummaryWrapperFormat(int|\WP_Post $post): string
109    {
110        $post = get_post($post);
111
112        if (!($post instanceof \WP_Post)) {
113            throw new \Exception(esc_html__('Post Not Found', 'speechkit'));
114        }
115
116        $summaryVoiceId = intval(get_post_meta($post->ID, 'beyondwords_summary_voice_id', true));
117
118        if ($summaryVoiceId > 0) {
119            return '<div data-beyondwords-summary="true" data-beyondwords-voice-id="' . $summaryVoiceId . '">%s</div>';
120        }
121
122        return '<div data-beyondwords-summary="true">%s</div>';
123    }
124
125    /**
126     * Get the post summary for the audio content.
127     *
128     * @param int|\WP_Post $post The WordPress post ID, or post object.
129     *
130     * @since 4.0.0
131     * @since 4.6.0 Renamed from PostContentUtils::getSummary() to PostContentUtils::getPostSummary()
132     *
133     * @return string The summary.
134     */
135    public static function getPostSummary(int|\WP_Post $post): string|null
136    {
137        $post = get_post($post);
138
139        if (!($post instanceof \WP_Post)) {
140            throw new \Exception(esc_html__('Post Not Found', 'speechkit'));
141        }
142
143        $summary = null;
144
145        // Optionally send the excerpt to the REST API, if the plugin setting has been checked
146        $prependExcerpt = get_option('beyondwords_prepend_excerpt');
147
148        if ($prependExcerpt && has_excerpt($post)) {
149            // Escape characters
150            $summary = htmlentities($post->post_excerpt, ENT_QUOTES | ENT_XHTML);
151            // Apply WordPress filters
152            // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Applying core WordPress filter
153            $summary = apply_filters('get_the_excerpt', $summary);
154            // Convert line breaks into paragraphs
155            $summary = trim(wpautop($summary));
156        }
157
158        return $summary;
159    }
160
161    /**
162     * Get the post content without blocks which have been filtered.
163     *
164     * We have added buttons into the Gutenberg editor to optionally exclude selected
165     * blocks from the source text for audio.
166     *
167     * This method filters all blocks, removing any which have been excluded.
168     *
169     * @param int|\WP_Post $post The WordPress post ID, or post object.
170     *
171     * @since 3.8.0
172     * @since 4.0.0 Replace for loop with array_reduce
173     * @since 6.0.0 Remove beyondwordsMarker attribute from rendered blocks.
174     *
175     * @return string The post body without excluded blocks.
176     */
177    public static function getContentWithoutExcludedBlocks(int|\WP_Post $post): string
178    {
179        if (! has_blocks($post)) {
180            return trim($post->post_content);
181        }
182
183        $blocks = parse_blocks($post->post_content);
184        $output = '';
185
186        $blocks = PostContentUtils::getAudioEnabledBlocks($post);
187
188        foreach ($blocks as $block) {
189            $output .= render_block($block);
190        }
191
192        return $output;
193    }
194
195    /**
196     * Get audio-enabled blocks.
197     *
198     * @param int|\WP_Post $post The WordPress post ID, or post object.
199     *
200     * @since 4.0.0
201     * @since 5.0.0 Remove beyondwords_post_audio_enabled_blocks filter.
202     *
203     * @return array The blocks.
204     */
205    public static function getAudioEnabledBlocks(int|\WP_Post $post): array
206    {
207        $post = get_post($post);
208
209        if (! ($post instanceof \WP_Post)) {
210            return [];
211        }
212
213        if (! has_blocks($post)) {
214            return [];
215        }
216
217        $allBlocks = parse_blocks($post->post_content);
218
219        return array_filter($allBlocks, function ($block) {
220            $enabled = true;
221
222            if (is_array($block['attrs']) && isset($block['attrs']['beyondwordsAudio'])) {
223                $enabled = (bool) $block['attrs']['beyondwordsAudio'];
224            }
225
226            return $enabled;
227        });
228    }
229
230    /**
231     * Get the body param we pass to the API.
232     *
233     * @since 3.0.0  Introduced as getBodyJson.
234     * @since 3.3.0  Added metadata to aid custom playlist generation.
235     * @since 3.5.0  Moved from Core\Utils to Component\Post\PostUtils.
236     * @since 3.10.4 Rename `published_at` API param to `publish_date`.
237     * @since 4.0.0  Use new API params.
238     * @since 4.0.3  Ensure `image_url` is always a string.
239     * @since 4.3.0  Rename from getBodyJson to getContentParams.
240     * @since 4.6.0  Remove summary param & prepend body with summary.
241     * @since 5.0.0  Remove beyondwords_body_params filter.
242     * @since 6.0.0  Cast return value to string.
243     *
244     * @static
245     * @param int $postId WordPress Post ID.
246     *
247     * @return string JSON endoded params.
248     **/
249    public static function getContentParams(int $postId): array|string
250    {
251        $body = [
252            'type'         => 'auto_segment',
253            'title'        => get_the_title($postId),
254            'body'         => PostContentUtils::getContentBody($postId),
255            'source_url'   => get_the_permalink($postId),
256            'source_id'    => strval($postId),
257            'author'       => PostContentUtils::getAuthorName($postId),
258            'image_url'    => strval(wp_get_original_image_url(get_post_thumbnail_id($postId))),
259            'metadata'     => PostContentUtils::getMetadata($postId),
260            'publish_date' => get_post_time(PostContentUtils::DATE_FORMAT, true, $postId),
261        ];
262
263        $status = get_post_status($postId);
264
265        /*
266         * If the post status is draft/pending then we explicity send
267         * { published: false } to the BeyondWords API, to prevent the
268         * generated audio from being published in playlists.
269         *
270         * We also omit { publish_date } because get_post_time() returns `false`
271         * for posts which are "Pending Review".
272         */
273        if (in_array($status, ['draft', 'pending'])) {
274            $body['published'] = false;
275            unset($body['publish_date']);
276        } elseif (get_option('beyondwords_project_auto_publish_enabled')) {
277            $body['published'] = true;
278        }
279
280        $languageCode = get_post_meta($postId, 'beyondwords_language_code', true);
281
282        if ($languageCode) {
283            $body['language'] = $languageCode;
284        }
285
286        $bodyVoiceId = intval(get_post_meta($postId, 'beyondwords_body_voice_id', true));
287
288        if ($bodyVoiceId > 0) {
289            $body['body_voice_id'] = $bodyVoiceId;
290        }
291
292        $titleVoiceId = intval(get_post_meta($postId, 'beyondwords_title_voice_id', true));
293
294        if ($titleVoiceId > 0) {
295            $body['title_voice_id'] = $titleVoiceId;
296        }
297
298        $summaryVoiceId = intval(get_post_meta($postId, 'beyondwords_summary_voice_id', true));
299
300        if ($summaryVoiceId > 0) {
301            $body['summary_voice_id'] = $summaryVoiceId;
302        }
303
304        /**
305         * Filters the params we send to the BeyondWords API 'content' endpoint.
306         *
307         * @since 4.0.0 Introduced as beyondwords_body_params
308         * @since 4.3.0 Renamed from beyondwords_body_params to beyondwords_content_params
309         *
310         * @param array $body   The params we send to the BeyondWords API.
311         * @param array $postId WordPress post ID.
312         */
313        $body = apply_filters('beyondwords_content_params', $body, $postId);
314
315        return (string) wp_json_encode($body);
316    }
317
318    /**
319     * Get the post metadata to send with BeyondWords API requests.
320     *
321     * The metadata key is defined by the BeyondWords API as "A custom object
322     * for storing meta information".
323     *
324     * The metadata values are used to create filters for playlists in the
325     * BeyondWords dashboard.
326     *
327     * We currently only include taxonomies by default, and the output of this
328     * method can be filtered using the `beyondwords_post_metadata` filter.
329     *
330     * @since 3.3.0
331     * @since 3.5.0 Moved from Core\Utils to Component\Post\PostUtils.
332     * @since 5.0.0 Remove beyondwords_post_metadata filter.
333     *
334     * @param int $postId Post ID.
335     *
336     * @return object The metadata object (empty if no metadata).
337     */
338    public static function getMetadata(int $postId): array|object
339    {
340        $metadata = new \stdClass();
341
342        $taxonomy = PostContentUtils::getAllTaxonomiesAndTerms($postId);
343
344        if (count((array)$taxonomy)) {
345            $metadata->taxonomy = $taxonomy;
346        }
347
348        return $metadata;
349    }
350
351    /**
352     * Get all taxonomies, and their selected terms, for a post.
353     *
354     * Returns an associative array of taxonomy names and terms.
355     *
356     * For example:
357     *
358     * array(
359     *     "categories" => array("Category 1"),
360     *     "post_tag" => array("Tag 1", "Tag 2", "Tag 3"),
361     * )
362     *
363     * @since 3.3.0
364     * @since 3.5.0 Moved from Core\Utils to Component\Post\PostUtils
365     *
366     * @param int $postId Post ID.
367     *
368     * @return object The taxonomies object (empty if no taxonomies).
369     */
370    public static function getAllTaxonomiesAndTerms(int $postId): array|object
371    {
372        $postType = get_post_type($postId);
373
374        $postTypeTaxonomies = get_object_taxonomies($postType);
375
376        $taxonomies = new \stdClass();
377
378        foreach ($postTypeTaxonomies as $postTypeTaxonomy) {
379            $terms = get_the_terms($postId, $postTypeTaxonomy);
380
381            if (! empty($terms) && ! is_wp_error($terms)) {
382                $taxonomies->{(string)$postTypeTaxonomy} = wp_list_pluck($terms, 'name');
383            }
384        }
385
386        return $taxonomies;
387    }
388
389    /**
390     * Get author name for a post.
391     *
392     * @since 3.10.4
393     *
394     * @param int $postId Post ID.
395     */
396    public static function getAuthorName(int $postId): string
397    {
398        $authorId = get_post_field('post_author', $postId);
399
400        return get_the_author_meta('display_name', $authorId);
401    }
402}