PluginProbe
BeyondWords – AI audio for publishers / 4.6.0
BeyondWords – AI audio for publishers v4.6.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 / Component / Post / PostContentUtils.php

PostContentUtils.php in BeyondWords – AI audio for publishers 4.6.0, at src/Component/Post/PostContentUtils.php

646 lines 20.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\Wordpress\Component\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 */
15 class PostContentUtils
16 {
17 public const DATE_FORMAT = 'Y-m-d\TH:i:s\Z';
18
19 /**
20 * Get the content "body" param for the audio, ready to be sent to the
21 * BeyondWords API.
22 *
23 * From API version 1.1 the "summary" param is going to be used differently,
24 * so for WordPress we now prepend the WordPress excerpt to the "body" param.
25 *
26 * @param int|WP_Post $post The WordPress post ID, or post object.
27 *
28 * @since 4.6.0
29 *
30 * @return string The content body param.
31 */
32 public static function getContentBody($post)
33 {
34 $post = get_post($post);
35
36 if (!($post instanceof \WP_Post)) {
37 throw new \Exception('Post Not Found');
38 }
39
40 $summary = PostContentUtils::getPostSummary($post);
41 $body = PostContentUtils::getPostBody($post);
42
43 if ($summary) {
44 $format = PostContentUtils::getPostSummaryWrapperFormat($post);
45
46 $body = sprintf($format, $summary) . $body;
47 }
48
49 return $body;
50 }
51
52 /**
53 * Get the post body for the audio content.
54 *
55 * The following rules are applied:
56 *
57 * Main post body content entered in WordPress
58 * + Optionally filtered using [SpeechKit-Start]/[SpeechKit-Stop] "shortcodes"
59 * + With registered content filters FROM OTHER PLUGINS applied
60 * + Optionally prepended with the Post excerpt
61 * + Optionally filtered using the beyondwords_content filter
62 *
63 * @SuppressWarnings(PHPMD.LongVariable)
64 *
65 * @param int|WP_Post $post The WordPress post ID, or post object.
66 *
67 * @since 3.0.0
68 * @since 3.5.0 Moved from Core\Utils to Component\Post\PostUtils
69 * @since 3.8.0 Exclude Gutenberg blocks with attribute { beyondwordsAudio: false }
70 * @since 4.0.0 Renamed from PostContentUtils::getSourceTextForAudio() to PostContentUtils::getBody()
71 * @since 4.6.0 Renamed from PostContentUtils::getBody() to PostContentUtils::getPostBody()
72 *
73 * @return string The body (the processed $post->post_content).
74 */
75 public static function getPostBody($post)
76 {
77 global $beyondwords_wordpress_plugin;
78
79 $post = get_post($post);
80
81 if (!($post instanceof \WP_Post)) {
82 throw new \Exception('Post Not Found');
83 }
84
85 $content = PostContentUtils::getContentWithoutExcludedBlocks($post);
86
87 // If SpeechKit-Start/Stop tags are present then use the content within them
88 // @deprecated v3.0.0: publishers should use the beyondwords_content filter instead.
89 $regex = '/\[SpeechKit-Start\](.*?)\[SpeechKit-Stop\]/s';
90
91 if (preg_match_all($regex, $content, $match, PREG_PATTERN_ORDER) > 0) {
92 $content = implode(' ', $match[1]);
93 }
94
95 // Temporarily remove our Player filter, to exclude the player <div>
96 if ($beyondwords_wordpress_plugin && isset($beyondwords_wordpress_plugin->player)) {
97 // @codeCoverageIgnoreStart
98 remove_filter('the_content', array($beyondwords_wordpress_plugin->player, 'autoPrependPlayer'));
99 // @codeCoverageIgnoreEnd
100 }
101
102 // Apply other standard WordPress filters to handle shortcodes etc
103 $content = apply_filters('the_content', $content);
104
105 // Add our Player filter back in again
106 if ($beyondwords_wordpress_plugin && isset($beyondwords_wordpress_plugin->player)) {
107 // @codeCoverageIgnoreStart
108 add_filter('the_content', array($beyondwords_wordpress_plugin->player, 'autoPrependPlayer'));
109 // @codeCoverageIgnoreEnd
110 }
111
112 // Trim to remove trailing newlines – common for WordPress content
113 $content = trim($content);
114
115 /**
116 * Filters the content body we send for audio processing.
117 *
118 * Scheduled for removal in plugin version 5.0.0.
119 *
120 * @since 4.0.0
121 *
122 * @deprecated 4.3.0 Set the 'body' key in beyondwords_content_params instead.
123 *
124 * @param string $content The post content.
125 * @param int $postId The post ID.
126 */
127 $content = apply_filters('beyondwords_content', $content, $post->ID);
128
129 return $content;
130 }
131
132 /**
133 * Get the post summary wrapper format.
134 *
135 * This is a <div> with optional attributes depending on the BeyondWords
136 * data of the post.
137 *
138 * @param int|WP_Post $post The WordPress post ID, or post object.
139 *
140 * @since 4.6.0
141 *
142 * @return string The summary wrapper <div>.
143 */
144 public static function getPostSummaryWrapperFormat($post)
145 {
146 $post = get_post($post);
147
148 if (!($post instanceof \WP_Post)) {
149 throw new \Exception('Post Not Found');
150 }
151
152 $summaryVoiceId = intval(get_post_meta($post->ID, 'beyondwords_summary_voice_id', true));
153
154 if ($summaryVoiceId > 0) {
155 return '<div data-beyondwords-summary="true" data-beyondwords-voice-id="' . $summaryVoiceId . '">%s</div>';
156 }
157
158 return '<div data-beyondwords-summary="true">%s</div>';
159 }
160
161 /**
162 * Get the post summary for the audio content.
163 *
164 * @param int|WP_Post $post The WordPress post ID, or post object.
165 *
166 * @since 4.0.0
167 * @since 4.6.0 Renamed from PostContentUtils::getSummary() to PostContentUtils::getPostSummary()
168 *
169 * @return string The summary.
170 */
171 public static function getPostSummary($post)
172 {
173 $post = get_post($post);
174
175 if (!($post instanceof \WP_Post)) {
176 throw new \Exception('Post Not Found');
177 }
178
179 $summary = null;
180
181 // Optionally send the excerpt to the REST API, if the plugin setting has been checked
182 $prependExcerpt = get_option('beyondwords_prepend_excerpt');
183
184 if ($prependExcerpt && has_excerpt($post)) {
185 // Escape characters
186 $summary = htmlentities($post->post_excerpt, ENT_QUOTES | ENT_XHTML);
187 // Apply WordPress filters
188 $summary = apply_filters('get_the_excerpt', $summary);
189 // Convert line breaks into paragraphs
190 $summary = trim(wpautop($summary));
191 }
192
193 return $summary;
194 }
195
196 /**
197 * Get the segments for the audio content, ready to be sent to the BeyondWords API.
198 *
199 * @codeCoverageIgnore
200 * THIS METHOD IS CURRENTLY NOT IN USE. Segments cannot currently include HTML
201 * formatting tags such as <strong> and <em> so we do not pass segments, we pass
202 * a HTML string as the body param instead.
203 *
204 * @param int|WP_Post $post The WordPress post ID, or post object.
205 *
206 * @since 4.0.0
207 *
208 * @return array|null The segments.
209 */
210 public static function getSegments($post)
211 {
212 if (! has_blocks($post)) {
213 return null;
214 }
215
216 $titleSegment = (object) [
217 'section' => 'title',
218 'text' => get_the_title($post),
219 ];
220
221 $summarySegment = (object) [
222 'section' => 'summary',
223 'text' => PostContentUtils::getPostSummary($post),
224 ];
225
226 $blocks = PostContentUtils::getAudioEnabledBlocks($post);
227
228 $bodySegments = array_map(function ($block) {
229 $marker = null;
230
231 if (isset($block['attrs']) && isset($block['attrs']['beyondwordsMarker'])) {
232 $marker = $block['attrs']['beyondwordsMarker'];
233 }
234
235 return (object) [
236 'section' => 'body',
237 'marker' => $marker,
238 'text' => trim(render_block($block)),
239 ];
240 }, $blocks);
241
242 // Merge title, summary and body segments
243 $segments = array_values(array_merge([$titleSegment], [$summarySegment], $bodySegments));
244
245 // Remove any segments with empty text
246 $segments = array_values(array_filter($segments, function ($segment) {
247 return (! empty($segment->text));
248 }));
249
250 return $segments;
251 }
252
253 /**
254 * Get the post content without blocks which have been filtered.
255 *
256 * We have added buttons into the Gutenberg editor to optionally exclude selected
257 * blocks from the source text for audio.
258 *
259 * This method filters all blocks, removing any which have been excluded.
260 *
261 * @param int|WP_Post $post The WordPress post ID, or post object.
262 *
263 * @since 3.8.0
264 * @since 4.0.0 Replace for loop with array_reduce
265 *
266 * @return string The post body without excluded blocks.
267 */
268 public static function getContentWithoutExcludedBlocks($post)
269 {
270 if (! has_blocks($post)) {
271 return trim($post->post_content);
272 }
273
274 $blocks = parse_blocks($post->post_content);
275 $output = '';
276
277 $blocks = PostContentUtils::getAudioEnabledBlocks($post);
278
279 foreach ($blocks as $block) {
280 $marker = $block['attrs']['beyondwordsMarker'] ?? '';
281
282 $output .= PostContentUtils::addMarkerAttribute(
283 render_block($block),
284 $marker
285 );
286 }
287
288 return $output;
289 }
290
291 /**
292 * Get audio-enabled blocks.
293 *
294 * @param int|WP_Post $post The WordPress post ID, or post object.
295 *
296 * @since 4.0.0
297 *
298 * @return array The blocks.
299 */
300 public static function getAudioEnabledBlocks($post)
301 {
302 $post = get_post($post);
303
304 if (! ($post instanceof \WP_Post)) {
305 return [];
306 }
307
308 if (! has_blocks($post)) {
309 return [];
310 }
311
312 $allBlocks = parse_blocks($post->post_content);
313
314 $blocks = array_filter($allBlocks, function ($block) {
315 $enabled = true;
316
317 if (is_array($block['attrs']) && isset($block['attrs']['beyondwordsAudio'])) {
318 $enabled = (bool) $block['attrs']['beyondwordsAudio'];
319 }
320
321 return $enabled;
322 });
323
324 /**
325 * Filters the audio-enabled blocks for a post.
326 *
327 * Scheduled for removal in plugin version 5.0.0.
328 *
329 * @since 4.0.0
330 *
331 * @deprecated 4.3.0 Replace with {@link https://docs.beyondwords.io/docs-and-guides/content/filter-content}.
332 *
333 * @param array $blocks The audio-enabled post blocks.
334 * @param array $allBlocks All post blocks including those with audio disabled.
335 * @param int $postId The post ID.
336 */
337 $blocks = apply_filters('beyondwords_post_audio_enabled_blocks', $blocks, $allBlocks, $post->ID);
338
339 return $blocks;
340 }
341
342 /**
343 * Get the body param we pass to the API.
344 *
345 * @since 3.0.0 Introduced as getBodyJson.
346 * @since 3.3.0 Added metadata to aid custom playlist generation.
347 * @since 3.5.0 Moved from Core\Utils to Component\Post\PostUtils.
348 * @since 3.10.4 Rename `published_at` API param to `publish_date`.
349 * @since 4.0.0 Use new API params.
350 * @since 4.0.3 Ensure `image_url` is always a string.
351 * @since 4.3.0 Rename from getBodyJson to getContentParams.
352 * @since 4.6.0 Remove summary param & prepend body with summary.
353 *
354 * @static
355 * @param int $postId WordPress Post ID.
356 *
357 * @return Response
358 **/
359 public static function getContentParams($postId)
360 {
361 $body = [
362 'type' => 'auto_segment',
363 'title' => get_the_title($postId),
364 'body' => PostContentUtils::getContentBody($postId),
365 'source_url' => get_the_permalink($postId),
366 'source_id' => strval($postId),
367 'author' => PostContentUtils::getAuthorName($postId),
368 'image_url' => strval(wp_get_original_image_url(get_post_thumbnail_id($postId))),
369 'metadata' => PostContentUtils::getMetadata($postId),
370 'published' => true,
371 'publish_date' => get_post_time(PostContentUtils::DATE_FORMAT, true, $postId),
372 ];
373
374 $status = get_post_status($postId);
375
376 /*
377 * If the post status is "pending" then we send { published: false } to
378 * the BeyondWords API, to prevent the generated audio from being
379 * published in playlists.
380 *
381 * We also omit { publish_date } because get_post_time() returns `false`
382 * for posts which are "Pending Review".
383 */
384 if ($status === 'pending') {
385 $body['published'] = false;
386 unset($body['publish_date']);
387 }
388
389 $bodyVoiceId = intval(get_post_meta($postId, 'beyondwords_body_voice_id', true));
390
391 if ($bodyVoiceId > 0) {
392 $body['body_voice_id'] = $bodyVoiceId;
393 }
394
395 $titleVoiceId = intval(get_post_meta($postId, 'beyondwords_title_voice_id', true));
396
397 if ($titleVoiceId > 0) {
398 $body['title_voice_id'] = $titleVoiceId;
399 }
400
401 /**
402 * Filters the params we send to the BeyondWords API 'content' endpoint.
403 *
404 * Scheduled for removal in plugin version 5.0.0.
405 *
406 * @since 4.0.0
407 *
408 * @deprecated 4.3.0 Replaced with beyondwords_content_params.
409 *
410 * @param array $body The params we send to the BeyondWords API.
411 * @param array $postId WordPress post ID.
412 */
413 $body = apply_filters('beyondwords_body_params', $body, $postId);
414
415 /**
416 * Filters the params we send to the BeyondWords API 'content' endpoint.
417 *
418 * @since 4.0.0 Introduced as beyondwords_body_params
419 * @since 4.3.0 Renamed from beyondwords_body_params to beyondwords_content_params
420 *
421 * @param array $body The params we send to the BeyondWords API.
422 * @param array $postId WordPress post ID.
423 */
424 $body = apply_filters('beyondwords_content_params', $body, $postId);
425
426 return wp_json_encode($body);
427 }
428
429 /**
430 * Get the post metadata to send with BeyondWords API requests.
431 *
432 * The metadata key is defined by the BeyondWords API as "A custom object
433 * for storing meta information".
434 *
435 * The metadata values are used to create filters for playlists in the
436 * BeyondWords dashboard.
437 *
438 * We currently only include taxonomies by default, and the output of this
439 * method can be filtered using the `beyondwords_post_metadata` filter.
440 *
441 * @since 3.3.0
442 * @since 3.5.0 Moved from Core\Utils to Component\Post\PostUtils
443 *
444 * @param int $postId Post ID.
445 *
446 * @return array
447 */
448 public static function getMetadata($postId)
449 {
450 $metadata = new \stdClass();
451
452 $taxonomy = PostContentUtils::getAllTaxonomiesAndTerms($postId);
453
454 if (count((array)$taxonomy)) {
455 $metadata->taxonomy = $taxonomy;
456 }
457
458 /**
459 * Filters the post metadata sent to the BeyondWords API.
460 *
461 * Scheduled for removal in plugin version 5.0.0.
462 *
463 * @since 3.3.0
464 *
465 * @deprecated 4.3.0 Set the 'metadata' key in beyondwords_content_params instead.
466 *
467 * @param object $metadata Post metadata. Defaults to the taxonomies and terms assigned to the post.
468 * @param int $postId Post ID.
469 */
470 $metadata = apply_filters('beyondwords_post_metadata', $metadata, $postId);
471
472 return $metadata;
473 }
474
475 /**
476 * Get all taxonomies, and their selected terms, for a post.
477 *
478 * Returns an associative array of taxonomy names and terms.
479 *
480 * For example:
481 *
482 * array(
483 * "categories" => array("Category 1"),
484 * "post_tag" => array("Tag 1", "Tag 2", "Tag 3"),
485 * )
486 *
487 * @since 3.3.0
488 * @since 3.5.0 Moved from Core\Utils to Component\Post\PostUtils
489 *
490 * @param int $postId Post ID.
491 *
492 * @return array
493 */
494 public static function getAllTaxonomiesAndTerms($postId)
495 {
496 $postType = get_post_type($postId);
497
498 $postTypeTaxonomies = get_object_taxonomies($postType);
499
500 $taxonomies = new \stdClass();
501
502 foreach ($postTypeTaxonomies as $postTypeTaxonomy) {
503 $terms = get_the_terms($postId, $postTypeTaxonomy);
504
505 if (! empty($terms) && ! is_wp_error($terms)) {
506 $taxonomies->{(string)$postTypeTaxonomy} = wp_list_pluck($terms, 'name');
507 }
508 }
509
510 return $taxonomies;
511 }
512
513 /**
514 * Get author name for a post.
515 *
516 * @since 3.10.4
517 *
518 * @param int $postId Post ID.
519 *
520 * @return string
521 */
522 public static function getAuthorName($postId)
523 {
524 $authorId = get_post_field('post_author', $postId);
525
526 return get_the_author_meta('display_name', $authorId);
527 }
528
529 /**
530 * Add data-beyondwords-marker attribute to the root elements in a HTML
531 * string (typically the rendered HTML of a single block).
532 *
533 * Checks to see whether we can use WP_HTML_Tag_Processor, or whether we
534 * fall back to using DOMDocument to add the marker.
535 *
536 * @since 4.2.2
537 *
538 * @param string $html HTML.
539 * @param string $marker Marker UUID.
540 *
541 * @return string HTML.
542 */
543 public static function addMarkerAttribute($html, $marker)
544 {
545 if (! $marker) {
546 return $html;
547 }
548
549 // Prefer WP_HTML_Tag_Processor, introduced in WordPress 6.2
550 if (class_exists('WP_HTML_Tag_Processor')) {
551 return PostContentUtils::addMarkerAttributeWithHTMLTagProcessor($html, $marker);
552 } else {
553 return PostContentUtils::addMarkerAttributeWithDOMDocument($html, $marker);
554 }
555 }
556
557 /**
558 * Add data-beyondwords-marker attribute to the root elements in a HTML
559 * string using WP_HTML_Tag_Processor.
560 *
561 * @since 4.0.0
562 * @since 4.2.2 Moved from src/Component/Post/BlockAttributes/BlockAttributes.php
563 * to src/Component/Post/PostContentUtils.php
564 *
565 * @param string $html HTML.
566 * @param string $marker Marker UUID.
567 *
568 * @return string HTML.
569 */
570 public static function addMarkerAttributeWithHTMLTagProcessor($html, $marker)
571 {
572 // https://github.com/WordPress/gutenberg/pull/42485
573 $tags = new \WP_HTML_Tag_Processor($html);
574
575 if ($tags->next_tag()) {
576 $tags->set_attribute('data-beyondwords-marker', $marker);
577 }
578
579 return strval($tags);
580 }
581
582 /**
583 * Add data-beyondwords-marker attribute to the root elements in a HTML
584 * string using DOMDocument.
585 *
586 * This is a fallback, since WP_HTML_Tag_Processor was only shipped with
587 * WordPress 6.2 on 19 April 2023.
588 *
589 * https://make.wordpress.org/core/2022/10/13/whats-new-in-gutenberg-14-3-12-october/
590 *
591 * Note: It is not ideal to do all the $bodyElement/$fullHtml processing
592 * in this method, but without it DOMDocument does not work as expected if
593 * there is more than 1 root element. The approach here has been taken from
594 * some historic Gutenberg code before they implemented WP_HTML_Tag_Processor:
595 *
596 * https://github.com/WordPress/gutenberg/blob/6671cef1179412a2bbd4969cbbc82705c7f69bac/lib/block-supports/index.php
597 *
598 * @since 4.0.0
599 * @since 4.2.2 Moved from src/Component/Post/BlockAttributes/BlockAttributes.php
600 * to src/Component/Post/PostContentUtils.php
601 *
602 * @param string $html HTML.
603 * @param string $marker Marker UUID.
604 *
605 * @return string HTML.
606 */
607 public static function addMarkerAttributeWithDOMDocument($html, $marker)
608 {
609 $dom = new \DOMDocument('1.0', 'utf-8');
610
611 $wrappedHtml =
612 '<html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"></head><body>'
613 . $html
614 . '</body></html>';
615
616 $success = $dom->loadHTML($wrappedHtml, LIBXML_HTML_NODEFDTD | LIBXML_COMPACT);
617
618 if (! $success) {
619 return $html;
620 }
621
622 // Structure is like `<html><head/><body/></html>`, so body is the `lastChild` of our document.
623 $bodyElement = $dom->documentElement->lastChild;
624
625 $xpath = new \DOMXPath($dom);
626 $blockRoot = $xpath->query('./*', $bodyElement)[0];
627
628 if (empty($blockRoot)) {
629 return $html;
630 }
631
632 $blockRoot->setAttribute('data-beyondwords-marker', $marker);
633
634 // Avoid using `$dom->saveHtml( $node )` because the node results may not produce consistent
635 // whitespace. Saving the root HTML `$dom->saveHtml()` prevents this behavior.
636 $fullHtml = $dom->saveHtml();
637
638 // Find the <body> open/close tags. The open tag needs to be adjusted so we get inside the tag
639 // and not the tag itself.
640 $start = strpos($fullHtml, '<body>', 0) + strlen('<body>');
641 $end = strpos($fullHtml, '</body>', $start);
642
643 return trim(substr($fullHtml, $start, $end - $start));
644 }
645 }
646