PluginProbe
BeyondWords – AI audio for publishers / 4.4.0
BeyondWords – AI audio for publishers v4.4.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.4.0, at src/Component/Post/PostContentUtils.php

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