PluginProbe
BeyondWords – AI audio for publishers / 7.1.0
BeyondWords – AI audio for publishers v7.1.0
7.2.0 7.1.0 trunk 4.0.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.1.0 4.1.1 4.1.2 4.2.0 4.2.1 4.2.2 4.2.3 4.2.4 4.3.0 4.4.0 4.5.0 4.5.1 4.6.0 4.6.1 4.6.2 All 44 releases
speechkit / src / post / class-content.php

class-content.php in BeyondWords – AI audio for publishers 7.1.0, at src/post/class-content.php

508 lines 15.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare( strict_types = 1 );
4
5 namespace BeyondWords\Post;
6
7 /**
8 * BeyondWords Post Content Utilities.
9 *
10 * @package Beyondwords
11 * @subpackage Beyondwords/includes
12 * @author Stuart McAlpine <[email protected]>
13 * @since 3.5.0
14 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
15 */
16 defined( 'ABSPATH' ) || exit;
17
18 class 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 }
508