Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.44% covered (success)
97.44%
152 / 156
76.92% covered (warning)
76.92%
10 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Content
97.42% covered (success)
97.42%
151 / 155
76.92% covered (warning)
76.92%
10 / 13
58
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
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 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%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 get_audio_enabled_blocks
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 filter_audio_enabled_blocks
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 is_audio_enabled_block
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 without_excluded_inner_blocks
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
6
 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_tags
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%
3 / 3
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     * @since 7.1.0 Render the per-block language and voice data attributes.
154     *
155     * @param int|\WP_Post $post The WordPress post ID, or post object.
156     *
157     * @return string The post body without excluded blocks.
158     */
159    public static function get_content_without_excluded_blocks( int|\WP_Post $post ): string {
160        $post = get_post( $post );
161
162        if ( ! ( $post instanceof \WP_Post ) ) {
163            throw new \Exception( esc_html__( 'Post Not Found', 'speechkit' ) );
164        }
165
166        if ( ! has_blocks( $post ) ) {
167            return trim( $post->post_content );
168        }
169
170        $output = '';
171
172        $blocks = self::get_audio_enabled_blocks( $post );
173
174        $segment_attributes = [ \BeyondWords\Editor\Components\BlockAttributes::class, 'add_segment_attributes' ];
175
176        // Per-block voices ride in the API body only, so the front end renders unchanged.
177        add_filter( 'render_block', $segment_attributes, 10, 2 );
178
179        try {
180            foreach ( $blocks as $block ) {
181                $output .= render_block( $block );
182            }
183        } finally {
184            remove_filter( 'render_block', $segment_attributes, 10 );
185        }
186
187        return $output;
188    }
189
190    /**
191     * Get audio-enabled blocks.
192     *
193     * @since 4.0.0
194     * @since 5.0.0 Remove beyondwords_post_audio_enabled_blocks filter.
195     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
196     * @since 7.1.0 Exclude blocks at any depth, not just top-level ones.
197     *
198     * @param int|\WP_Post $post The WordPress post ID, or post object.
199     *
200     * @return array The blocks.
201     */
202    public static function get_audio_enabled_blocks( int|\WP_Post $post ): array {
203        $post = get_post( $post );
204
205        if ( ! ( $post instanceof \WP_Post ) ) {
206            return [];
207        }
208
209        if ( ! has_blocks( $post ) ) {
210            return [];
211        }
212
213        return self::filter_audio_enabled_blocks( parse_blocks( $post->post_content ) );
214    }
215
216    /**
217     * Drop the blocks an editor excluded from the audio, at every depth.
218     *
219     * @since 7.1.0
220     */
221    private static function filter_audio_enabled_blocks( array $blocks ): array {
222        $kept = [];
223
224        foreach ( $blocks as $block ) {
225            if ( ! self::is_audio_enabled_block( $block ) ) {
226                continue;
227            }
228
229            $kept[] = empty( $block['innerBlocks'] )
230                ? $block
231                : self::without_excluded_inner_blocks( $block );
232        }
233
234        return $kept;
235    }
236
237    /**
238     * Whether a parsed block is included in the audio.
239     *
240     * @since 7.1.0
241     */
242    private static function is_audio_enabled_block( $block ): bool {
243        if ( ! is_array( $block ) || ! is_array( $block['attrs'] ?? null ) ) {
244            return true;
245        }
246
247        if ( ! isset( $block['attrs']['beyondwordsAudio'] ) ) {
248            return true;
249        }
250
251        return (bool) $block['attrs']['beyondwordsAudio'];
252    }
253
254    /**
255     * Drop a block's excluded descendants.
256     *
257     * `innerContent` carries one null per inner block, in order, and is what
258     * render_block() walks â€” so dropping a child means dropping its placeholder
259     * too, or the remaining children render in the wrong places.
260     *
261     * @since 7.1.0
262     */
263    private static function without_excluded_inner_blocks( array $block ): array {
264        $inner_blocks  = [];
265        $inner_content = [];
266        $index         = 0;
267
268        foreach ( (array) ( $block['innerContent'] ?? [] ) as $chunk ) {
269            if ( null !== $chunk ) {
270                $inner_content[] = $chunk;
271                continue;
272            }
273
274            $child = $block['innerBlocks'][ $index ] ?? null;
275            ++$index;
276
277            if ( ! is_array( $child ) || ! self::is_audio_enabled_block( $child ) ) {
278                continue;
279            }
280
281            $inner_blocks[]  = empty( $child['innerBlocks'] )
282                ? $child
283                : self::without_excluded_inner_blocks( $child );
284            $inner_content[] = null;
285        }
286
287        $block['innerBlocks']  = $inner_blocks;
288        $block['innerContent'] = $inner_content;
289
290        return $block;
291    }
292
293    /**
294     * Get the body param we pass to the API.
295     *
296     * @since 3.0.0  Introduced as getBodyJson.
297     * @since 3.3.0  Added metadata to aid custom playlist generation.
298     * @since 3.5.0  Moved from Core\Utils to Component\Post\PostUtils.
299     * @since 3.10.4 Rename `published_at` API param to `publish_date`.
300     * @since 4.0.0  Use new API params.
301     * @since 4.0.3  Ensure `image_url` is always a string.
302     * @since 4.3.0  Rename from getBodyJson to getContentParams.
303     * @since 4.6.0  Remove summary param & prepend body with summary.
304     * @since 5.0.0  Remove beyondwords_body_params filter.
305     * @since 6.0.0  Cast return value to string.
306     * @since 7.0.0  Replace the `metadata` param with a flat `tags` array.
307     *
308     * @static
309     * @param int $post_id WordPress Post ID.
310     *
311     * @return string JSON encoded params.
312     **/
313    public static function get_content_params( int $post_id ): array|string {
314        $body = [
315            'type'         => 'auto_segment',
316            'title'        => get_the_title( $post_id ),
317            'body'         => self::get_content_body( $post_id ),
318            'source_url'   => get_the_permalink( $post_id ),
319            'source_id'    => strval( $post_id ),
320            'author'       => self::get_author_name( $post_id ),
321            'image_url'    => strval( wp_get_original_image_url( get_post_thumbnail_id( $post_id ) ) ),
322            'tags'         => self::get_tags( $post_id ),
323            'publish_date' => get_post_time( self::DATE_FORMAT, true, $post_id ),
324        ];
325
326        $status = get_post_status( $post_id );
327
328        // Drafts send { published: false } to keep the audio out of playlists, and
329        // omit publish_date because get_post_time() is false for pending posts.
330        if ( in_array( $status, [ 'draft', 'pending'] ) ) {
331            $body['published'] = false;
332            unset( $body['publish_date'] );
333        } else {
334            /**
335             * Filters whether generated content is auto-published to BeyondWords.
336             *
337             * Replaces the v6.x `beyondwords_project_auto_publish_enabled` setting.
338             *
339             * @since 7.0.0
340             *
341             * @param bool $auto_publish Whether to mark generated content as published.
342             * @param int  $post_id       WordPress post ID.
343             */
344            $auto_publish = apply_filters( 'beyondwords_auto_publish', true, $post_id );
345
346            if ( $auto_publish ) {
347                $body['published'] = true;
348            }
349        }
350
351        // The language is never sent: a chosen voice implies it, and with no voice
352        // the project default applies. `beyondwords_language_code` is editor state only.
353        $body_voice_id = intval( get_post_meta( $post_id, 'beyondwords_body_voice_id', true ) );
354
355        if ( $body_voice_id > 0 ) {
356            $body['body_voice_id'] = $body_voice_id;
357        }
358
359        // Omitted when Source is Post (or unset) so the project default applies.
360        $source = \BeyondWords\Editor\Components\SettingsFields::get_source( $post_id );
361
362        if ( \BeyondWords\Editor\Components\SettingsFields::source_includes_script( $source ) ) {
363            $body['summarization_settings'] = [ 'enabled' => true ];
364
365            $script_template_id = intval(
366                get_post_meta( $post_id, 'beyondwords_script_template_id', true )
367            );
368
369            if ( $script_template_id > 0 ) {
370                $body['summarization_settings']['template'] = [
371                    'id' => $script_template_id,
372                ];
373            }
374        }
375
376        $output = get_post_meta( $post_id, 'beyondwords_output', true );
377
378        if ( in_array( $output, [ 'video', 'audio_and_video' ], true ) ) {
379            $body['video_settings'] = self::get_video_settings_params( $post_id );
380        }
381
382        /**
383         * Filters the params we send to the BeyondWords API 'content' endpoint.
384         *
385         * @since 4.0.0 Introduced as beyondwords_body_params
386         * @since 4.3.0 Renamed from beyondwords_body_params to beyondwords_content_params
387         *
388         * @param array $body   The params we send to the BeyondWords API.
389         * @param array $post_id WordPress post ID.
390         */
391        $body = apply_filters( 'beyondwords_content_params', $body, $post_id );
392
393        return (string) wp_json_encode( $body );
394    }
395
396    /**
397     * Build the `video_settings` param sent to the BeyondWords content endpoint.
398     *
399     * The backend silently skips video generation unless the payload has `enabled:
400     * true` plus non-empty `variants` and `sizes` (with dimensions), so we seed
401     * from the project defaults and layer the post's choices on top. See doc/video-settings-payload.md.
402     *
403     * @since 7.0.0
404     *
405     * @param int $post_id WordPress post ID.
406     *
407     * @return array<string, mixed> The `video_settings` param.
408     */
409    private static function get_video_settings_params( int $post_id ): array {
410        // The post's project may be a per-post override of the global one.
411        $project_id = \BeyondWords\Post\Meta::get_project_id( $post_id );
412        $defaults   = \BeyondWords\Api\Client::get_video_settings( is_numeric( $project_id ) ? (int) $project_id : null );
413        $defaults   = is_array( $defaults ) ? $defaults : [];
414
415        $settings = [ 'enabled' => true ];
416
417        // The backend needs a non-empty `variants`; there is no per-post variant
418        // control, so echo the project defaults.
419        if ( ! empty( $defaults['variants'] ) && is_array( $defaults['variants'] ) ) {
420            $settings['variants'] = array_values( $defaults['variants'] );
421        }
422
423        // Echo the project sizes (the backend requires width/height), enabling
424        // only the post's chosen size when one is set.
425        $video_size    = (string) get_post_meta( $post_id, 'beyondwords_video_size', true );
426        $default_sizes = ( isset( $defaults['sizes'] ) && is_array( $defaults['sizes'] ) ) ? $defaults['sizes'] : [];
427
428        $sizes = [];
429
430        foreach ( $default_sizes as $size ) {
431            if ( ! is_array( $size ) || ! isset( $size['name'] ) ) {
432                continue;
433            }
434
435            $sizes[] = [
436                'name'    => (string) $size['name'],
437                'width'   => (int) ( $size['width'] ?? 0 ),
438                'height'  => (int) ( $size['height'] ?? 0 ),
439                'enabled' => '' !== $video_size
440                    ? ( (string) $size['name'] === $video_size )
441                    : (bool) ( $size['enabled'] ?? false ),
442            ];
443        }
444
445        if ( ! empty( $sizes ) ) {
446            $settings['sizes'] = $sizes;
447        }
448
449        // Omit `template` to defer to the project default.
450        $video_template_id = intval( get_post_meta( $post_id, 'beyondwords_video_template_id', true ) );
451
452        if ( $video_template_id > 0 ) {
453            $settings['template'] = [ 'id' => $video_template_id ];
454        }
455
456        return $settings;
457    }
458
459    /**
460     * Get the taxonomy terms to send as the `tags` param.
461     *
462     * The values are used to create playlist filters in the BeyondWords dashboard.
463     *
464     * @since 3.3.0 Introduced as get_metadata(), sending a metadata.taxonomy object.
465     * @since 3.5.0 Moved from Core\Utils to Component\Post\PostUtils.
466     * @since 5.0.0 Remove beyondwords_post_metadata filter.
467     * @since 7.0.0 Renamed to get_tags(), returning a flat array of term names.
468     *
469     * @return string[] Term names from every taxonomy of the post type.
470     */
471    public static function get_tags( int $post_id ): array {
472        $taxonomies = get_object_taxonomies( (string) get_post_type( $post_id ) );
473
474        $tags = [];
475
476        foreach ( $taxonomies as $taxonomy ) {
477            $terms = get_the_terms( $post_id, $taxonomy );
478
479            if ( ! empty( $terms ) && ! is_wp_error( $terms ) ) {
480                $tags = array_merge( $tags, wp_list_pluck( $terms, 'name' ) );
481            }
482        }
483
484        // Core stores term names HTML-encoded, so "R&D" would reach the API as "R&amp;D".
485        $tags = array_map( fn( $tag ) => wp_specialchars_decode( $tag, ENT_QUOTES ), $tags );
486
487        // Terms in different taxonomies can share a name, and the API wants each tag once.
488        return array_values( array_unique( $tags ) );
489    }
490
491    /**
492     * Get author name for a post.
493     *
494     * @since 3.10.4
495     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
496     *
497     * @param int $post_id Post ID.
498     */
499    public static function get_author_name( int $post_id ): string {
500        $author_id = get_post_field( 'post_author', $post_id );
501
502        $name = get_the_author_meta( 'display_name', $author_id );
503
504        // Core stores display names HTML-encoded, so "Smith & Sons" would reach the API as "Smith &amp; Sons".
505        return wp_specialchars_decode( $name, ENT_QUOTES );
506    }
507}