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

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

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