PluginProbe
BeyondWords – AI audio for publishers / 7.0.0
BeyondWords – AI audio for publishers v7.0.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 4.7.0 All 43 releases
speechkit / src / post / class-content.php

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

433 lines 13.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 <stu@beyondwords.io>
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 *
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 * @since 7.0.0 Replace the `metadata` param with a flat `tags` array.
232 *
233 * @static
234 * @param int $post_id WordPress Post ID.
235 *
236 * @return string JSON encoded params.
237 **/
238 public static function get_content_params( int $post_id ): array|string {
239 $body = [
240 'type' => 'auto_segment',
241 'title' => get_the_title( $post_id ),
242 'body' => self::get_content_body( $post_id ),
243 'source_url' => get_the_permalink( $post_id ),
244 'source_id' => strval( $post_id ),
245 'author' => self::get_author_name( $post_id ),
246 'image_url' => strval( wp_get_original_image_url( get_post_thumbnail_id( $post_id ) ) ),
247 'tags' => self::get_tags( $post_id ),
248 'publish_date' => get_post_time( self::DATE_FORMAT, true, $post_id ),
249 ];
250
251 $status = get_post_status( $post_id );
252
253 // Drafts send { published: false } to keep the audio out of playlists, and
254 // omit publish_date because get_post_time() is false for pending posts.
255 if ( in_array( $status, [ 'draft', 'pending'] ) ) {
256 $body['published'] = false;
257 unset( $body['publish_date'] );
258 } else {
259 /**
260 * Filters whether generated content is auto-published to BeyondWords.
261 *
262 * Replaces the v6.x `beyondwords_project_auto_publish_enabled` setting.
263 *
264 * @since 7.0.0
265 *
266 * @param bool $auto_publish Whether to mark generated content as published.
267 * @param int $post_id WordPress post ID.
268 */
269 $auto_publish = apply_filters( 'beyondwords_auto_publish', true, $post_id );
270
271 if ( $auto_publish ) {
272 $body['published'] = true;
273 }
274 }
275
276 // The language is never sent: a chosen voice implies it, and with no voice
277 // the project default applies. `beyondwords_language_code` is editor state only.
278 $body_voice_id = intval( get_post_meta( $post_id, 'beyondwords_body_voice_id', true ) );
279
280 if ( $body_voice_id > 0 ) {
281 $body['body_voice_id'] = $body_voice_id;
282 }
283
284 // Omitted when Source is Post (or unset) so the project default applies.
285 $source = \BeyondWords\Editor\Components\SettingsFields::get_source( $post_id );
286
287 if ( \BeyondWords\Editor\Components\SettingsFields::source_includes_script( $source ) ) {
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 taxonomy terms to send as the `tags` param.
386 *
387 * The values are used to create playlist filters in the BeyondWords dashboard.
388 *
389 * @since 3.3.0 Introduced as get_metadata(), sending a metadata.taxonomy object.
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 Renamed to get_tags(), returning a flat array of term names.
393 *
394 * @return string[] Term names from every taxonomy of the post type.
395 */
396 public static function get_tags( int $post_id ): array {
397 $taxonomies = get_object_taxonomies( (string) get_post_type( $post_id ) );
398
399 $tags = [];
400
401 foreach ( $taxonomies as $taxonomy ) {
402 $terms = get_the_terms( $post_id, $taxonomy );
403
404 if ( ! empty( $terms ) && ! is_wp_error( $terms ) ) {
405 $tags = array_merge( $tags, wp_list_pluck( $terms, 'name' ) );
406 }
407 }
408
409 // Core stores term names HTML-encoded, so "R&D" would reach the API as "R&amp;D".
410 $tags = array_map( fn( $tag ) => wp_specialchars_decode( $tag, ENT_QUOTES ), $tags );
411
412 // Terms in different taxonomies can share a name, and the API wants each tag once.
413 return array_values( array_unique( $tags ) );
414 }
415
416 /**
417 * Get author name for a post.
418 *
419 * @since 3.10.4
420 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
421 *
422 * @param int $post_id Post ID.
423 */
424 public static function get_author_name( int $post_id ): string {
425 $author_id = get_post_field( 'post_author', $post_id );
426
427 $name = get_the_author_meta( 'display_name', $author_id );
428
429 // Core stores display names HTML-encoded, so "Smith & Sons" would reach the API as "Smith &amp; Sons".
430 return wp_specialchars_decode( $name, ENT_QUOTES );
431 }
432 }
433