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

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