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

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