Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.30% covered (success)
96.30%
130 / 135
72.73% covered (warning)
72.73%
8 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Content
96.27% covered (success)
96.27%
129 / 134
72.73% covered (warning)
72.73%
8 / 11
48
0.00% covered (danger)
0.00%
0 / 1
 get_content_body
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 get_post_body
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
3.10
 get_post_summary_wrapper_format
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 get_post_summary
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 get_content_without_excluded_blocks
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 get_audio_enabled_blocks
86.67% covered (success)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
5.06
 get_content_params
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
7
 get_video_settings_params
96.15% covered (success)
96.15%
25 / 26
0.00% covered (danger)
0.00%
0 / 1
13
 get_metadata
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 get_all_taxonomies_and_terms
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 get_author_name
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\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 * @since      7.0.0 Refactored to BeyondWords namespace with snake_case methods.
15 */
16defined( 'ABSPATH' ) || exit;
17
18class Content {
19
20    public const DATE_FORMAT = 'Y-m-d\TH:i:s\Z';
21
22    /**
23     * Get the content "body" param for the audio.
24     *
25     * The excerpt is prepended to the body because API v1.1 repurposed the
26     * "summary" param.
27     *
28     * @since 4.6.0
29     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
30     *
31     * @param int|\WP_Post $post The WordPress post ID, or post object.
32     *
33     * @return string The content body param.
34     */
35    public static function get_content_body( int|\WP_Post $post ): string|null {
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 = self::get_post_summary( $post );
43        $body    = self::get_post_body( $post );
44
45        if ( $summary ) {
46            $format = self::get_post_summary_wrapper_format( $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 Content::getSourceTextForAudio() to Content::getBody()
61     * @since 4.6.0 Renamed from Content::getBody() to Content::get_post_body()
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     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
66     *
67     * @param int|\WP_Post $post The WordPress post ID, or post object.
68     *
69     * @return string The body (the processed $post->post_content).
70     */
71    public static function get_post_body( int|\WP_Post $post ): string|null {
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 = self::get_content_without_excluded_blocks( $post );
79
80        if ( has_blocks( $post ) ) {
81            // wpautop breaks our HTML markup when block editor paragraphs are empty,
82            // but we still want to remove the empty lines it would have handled.
83            remove_filter( 'the_content', 'wpautop' );
84
85            $content = preg_replace( '/^\h*\v+/m', '', $content );
86        }
87
88        // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Applying core WordPress filter
89        $content = apply_filters( 'the_content', $content );
90
91        return trim( $content );
92    }
93
94    /**
95     * Get the post summary wrapper format.
96     *
97     * @since 4.6.0
98     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
99     *
100     * @param int|\WP_Post $post The WordPress post ID, or post object.
101     *
102     * @return string The summary wrapper <div>.
103     */
104    public static function get_post_summary_wrapper_format( int|\WP_Post $post ): string {
105        $post = get_post( $post );
106
107        if ( ! ( $post instanceof \WP_Post ) ) {
108            throw new \Exception( esc_html__( 'Post Not Found', 'speechkit' ) );
109        }
110
111        return '<div data-beyondwords-summary="true">%s</div>';
112    }
113
114    /**
115     * Get the post summary for the audio content.
116     *
117     * @since 4.0.0
118     * @since 4.6.0 Renamed from Content::getSummary() to Content::get_post_summary()
119     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
120     *
121     * @param int|\WP_Post $post The WordPress post ID, or post object.
122     *
123     * @return string The summary.
124     */
125    public static function get_post_summary( int|\WP_Post $post ): string|null {
126        $post = get_post( $post );
127
128        if ( ! ( $post instanceof \WP_Post ) ) {
129            throw new \Exception( esc_html__( 'Post Not Found', 'speechkit' ) );
130        }
131
132        $summary = null;
133
134        $prepend_excerpt = get_option( 'beyondwords_prepend_excerpt' );
135
136        if ( $prepend_excerpt && has_excerpt( $post ) ) {
137            $summary = htmlentities( $post->post_excerpt, ENT_QUOTES | ENT_XHTML );
138            // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Applying core WordPress filter
139            $summary = apply_filters( 'get_the_excerpt', $summary );
140            $summary = trim( wpautop( $summary ) );
141        }
142
143        return $summary;
144    }
145
146    /**
147     * Get the post content without the blocks an editor excluded from audio.
148     *
149     * @since 3.8.0
150     * @since 4.0.0 Replace for loop with array_reduce
151     * @since 6.0.0 Remove beyondwordsMarker attribute from rendered blocks.
152     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
153     *
154     * @param int|\WP_Post $post The WordPress post ID, or post object.
155     *
156     * @return string The post body without excluded blocks.
157     */
158    public static function get_content_without_excluded_blocks( int|\WP_Post $post ): string {
159        $post = get_post( $post );
160
161        if ( ! ( $post instanceof \WP_Post ) ) {
162            throw new \Exception( esc_html__( 'Post Not Found', 'speechkit' ) );
163        }
164
165        if ( ! has_blocks( $post ) ) {
166            return trim( $post->post_content );
167        }
168
169        $output = '';
170
171        $blocks = self::get_audio_enabled_blocks( $post );
172
173        foreach ( $blocks as $block ) {
174            $output .= render_block( $block );
175        }
176
177        return $output;
178    }
179
180    /**
181     * Get audio-enabled blocks.
182     *
183     * @since 4.0.0
184     * @since 5.0.0 Remove beyondwords_post_audio_enabled_blocks filter.
185     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
186     *
187     * @param int|\WP_Post $post The WordPress post ID, or post object.
188     *
189     * @return array The blocks.
190     */
191    public static function get_audio_enabled_blocks( int|\WP_Post $post ): array {
192        $post = get_post( $post );
193
194        if ( ! ( $post instanceof \WP_Post ) ) {
195            return [];
196        }
197
198        if ( ! has_blocks( $post ) ) {
199            return [];
200        }
201
202        $all_blocks = parse_blocks( $post->post_content );
203
204        return array_filter(
205            $all_blocks,
206            function ( $block ) {
207                $enabled = true;
208
209                if ( is_array( $block['attrs'] ) && isset( $block['attrs']['beyondwordsAudio'] ) ) {
210                    $enabled = (bool) $block['attrs']['beyondwordsAudio'];
211                }
212
213                return $enabled;
214            }
215        );
216    }
217
218    /**
219     * Get the body param we pass to the API.
220     *
221     * @since 3.0.0  Introduced as getBodyJson.
222     * @since 3.3.0  Added metadata to aid custom playlist generation.
223     * @since 3.5.0  Moved from Core\Utils to Component\Post\PostUtils.
224     * @since 3.10.4 Rename `published_at` API param to `publish_date`.
225     * @since 4.0.0  Use new API params.
226     * @since 4.0.3  Ensure `image_url` is always a string.
227     * @since 4.3.0  Rename from getBodyJson to getContentParams.
228     * @since 4.6.0  Remove summary param & prepend body with summary.
229     * @since 5.0.0  Remove beyondwords_body_params filter.
230     * @since 6.0.0  Cast return value to string.
231     *
232     * @static
233     * @param int $post_id WordPress Post ID.
234     *
235     * @return string JSON encoded params.
236     **/
237    public static function get_content_params( int $post_id ): array|string {
238        $body = [
239            'type'         => 'auto_segment',
240            'title'        => get_the_title( $post_id ),
241            'body'         => self::get_content_body( $post_id ),
242            'source_url'   => get_the_permalink( $post_id ),
243            'source_id'    => strval( $post_id ),
244            'author'       => self::get_author_name( $post_id ),
245            'image_url'    => strval( wp_get_original_image_url( get_post_thumbnail_id( $post_id ) ) ),
246            'metadata'     => self::get_metadata( $post_id ),
247            'publish_date' => get_post_time( self::DATE_FORMAT, true, $post_id ),
248        ];
249
250        $status = get_post_status( $post_id );
251
252        // Drafts send { published: false } to keep the audio out of playlists, and
253        // omit publish_date because get_post_time() is false for pending posts.
254        if ( in_array( $status, [ 'draft', 'pending'] ) ) {
255            $body['published'] = false;
256            unset( $body['publish_date'] );
257        } else {
258            /**
259             * Filters whether generated content is auto-published to BeyondWords.
260             *
261             * Replaces the v6.x `beyondwords_project_auto_publish_enabled` setting.
262             *
263             * @since 7.0.0
264             *
265             * @param bool $auto_publish Whether to mark generated content as published.
266             * @param int  $post_id       WordPress post ID.
267             */
268            $auto_publish = apply_filters( 'beyondwords_auto_publish', true, $post_id );
269
270            if ( $auto_publish ) {
271                $body['published'] = true;
272            }
273        }
274
275        // The language is never sent: a chosen voice implies it, and with no voice
276        // the project default applies. `beyondwords_language_code` is editor state only.
277        $body_voice_id = intval( get_post_meta( $post_id, 'beyondwords_body_voice_id', true ) );
278
279        if ( $body_voice_id > 0 ) {
280            $body['body_voice_id'] = $body_voice_id;
281        }
282
283        // Source = Script or Post + script â†’ enable summarization; omitted when
284        // Source is Post (or unset) so the project default applies.
285        $source = get_post_meta( $post_id, 'beyondwords_source', true );
286
287        if ( in_array( $source, [ 'script', 'post_and_script' ], true ) ) {
288            $body['summarization_settings'] = [ 'enabled' => true ];
289
290            $script_template_id = intval(
291                get_post_meta( $post_id, 'beyondwords_script_template_id', true )
292            );
293
294            if ( $script_template_id > 0 ) {
295                $body['summarization_settings']['template'] = [
296                    'id' => $script_template_id,
297                ];
298            }
299        }
300
301        $output = get_post_meta( $post_id, 'beyondwords_output', true );
302
303        if ( in_array( $output, [ 'video', 'audio_and_video' ], true ) ) {
304            $body['video_settings'] = self::get_video_settings_params( $post_id );
305        }
306
307        /**
308         * Filters the params we send to the BeyondWords API 'content' endpoint.
309         *
310         * @since 4.0.0 Introduced as beyondwords_body_params
311         * @since 4.3.0 Renamed from beyondwords_body_params to beyondwords_content_params
312         *
313         * @param array $body   The params we send to the BeyondWords API.
314         * @param array $post_id WordPress post ID.
315         */
316        $body = apply_filters( 'beyondwords_content_params', $body, $post_id );
317
318        return (string) wp_json_encode( $body );
319    }
320
321    /**
322     * Build the `video_settings` param sent to the BeyondWords content endpoint.
323     *
324     * The backend silently skips video generation unless the payload has `enabled:
325     * true` plus non-empty `variants` and `sizes` (with dimensions), so we seed
326     * from the project defaults and layer the post's choices on top. See doc/video-settings-payload.md.
327     *
328     * @since 7.0.0
329     *
330     * @param int $post_id WordPress post ID.
331     *
332     * @return array<string, mixed> The `video_settings` param.
333     */
334    private static function get_video_settings_params( int $post_id ): array {
335        // The post's project may be a per-post override of the global one.
336        $project_id = \BeyondWords\Post\Meta::get_project_id( $post_id );
337        $defaults   = \BeyondWords\Api\Client::get_video_settings( is_numeric( $project_id ) ? (int) $project_id : null );
338        $defaults   = is_array( $defaults ) ? $defaults : [];
339
340        $settings = [ 'enabled' => true ];
341
342        // The backend needs a non-empty `variants`; there is no per-post variant
343        // control, so echo the project defaults.
344        if ( ! empty( $defaults['variants'] ) && is_array( $defaults['variants'] ) ) {
345            $settings['variants'] = array_values( $defaults['variants'] );
346        }
347
348        // Echo the project sizes (the backend requires width/height), enabling
349        // only the post's chosen size when one is set.
350        $video_size    = (string) get_post_meta( $post_id, 'beyondwords_video_size', true );
351        $default_sizes = ( isset( $defaults['sizes'] ) && is_array( $defaults['sizes'] ) ) ? $defaults['sizes'] : [];
352
353        $sizes = [];
354
355        foreach ( $default_sizes as $size ) {
356            if ( ! is_array( $size ) || ! isset( $size['name'] ) ) {
357                continue;
358            }
359
360            $sizes[] = [
361                'name'    => (string) $size['name'],
362                'width'   => (int) ( $size['width'] ?? 0 ),
363                'height'  => (int) ( $size['height'] ?? 0 ),
364                'enabled' => '' !== $video_size
365                    ? ( (string) $size['name'] === $video_size )
366                    : (bool) ( $size['enabled'] ?? false ),
367            ];
368        }
369
370        if ( ! empty( $sizes ) ) {
371            $settings['sizes'] = $sizes;
372        }
373
374        // Omit `template` to defer to the project default.
375        $video_template_id = intval( get_post_meta( $post_id, 'beyondwords_video_template_id', true ) );
376
377        if ( $video_template_id > 0 ) {
378            $settings['template'] = [ 'id' => $video_template_id ];
379        }
380
381        return $settings;
382    }
383
384    /**
385     * Get the post metadata to send with BeyondWords API requests.
386     *
387     * The values are used to create playlist filters in the BeyondWords dashboard.
388     *
389     * @since 3.3.0
390     * @since 3.5.0 Moved from Core\Utils to Component\Post\PostUtils.
391     * @since 5.0.0 Remove beyondwords_post_metadata filter.
392     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
393     *
394     * @param int $post_id Post ID.
395     *
396     * @return object The metadata object (empty if no metadata).
397     */
398    public static function get_metadata( int $post_id ): array|object {
399        $metadata = new \stdClass();
400
401        $taxonomy = self::get_all_taxonomies_and_terms( $post_id );
402
403        if ( count( (array) $taxonomy ) ) {
404            $metadata->taxonomy = $taxonomy;
405        }
406
407        return $metadata;
408    }
409
410    /**
411     * Get all taxonomies, and their selected terms, for a post.
412     *
413     * @since 3.3.0
414     * @since 3.5.0 Moved from Core\Utils to Component\Post\PostUtils
415     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
416     *
417     * @param int $post_id Post ID.
418     *
419     * @return object The taxonomies object (empty if no taxonomies).
420     */
421    public static function get_all_taxonomies_and_terms( int $post_id ): array|object {
422        $post_type = get_post_type( $post_id );
423
424        $post_type_taxonomies = get_object_taxonomies( $post_type );
425
426        $taxonomies = new \stdClass();
427
428        foreach ( $post_type_taxonomies as $post_type_taxonomy ) {
429            $terms = get_the_terms( $post_id, $post_type_taxonomy );
430
431            if ( ! empty( $terms ) && ! is_wp_error( $terms ) ) {
432                $taxonomies->{ (string) $post_type_taxonomy} = wp_list_pluck( $terms, 'name' );
433            }
434        }
435
436        return $taxonomies;
437    }
438
439    /**
440     * Get author name for a post.
441     *
442     * @since 3.10.4
443     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
444     *
445     * @param int $post_id Post ID.
446     */
447    public static function get_author_name( int $post_id ): string {
448        $author_id = get_post_field( 'post_author', $post_id );
449
450        return get_the_author_meta( 'display_name', $author_id );
451    }
452}