PluginProbe
BeyondWords – AI audio for publishers / 4.4.0
BeyondWords – AI audio for publishers v4.4.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 / Core / Core.php

Core.php in BeyondWords – AI audio for publishers 4.4.0, at src/Core/Core.php

606 lines 19.4 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\Core;
6
7 use Beyondwords\Wordpress\Component\Post\PostContentUtils;
8 use Beyondwords\Wordpress\Component\Post\PostMetaUtils;
9 use Beyondwords\Wordpress\Component\Settings\SettingsUtils;
10 use Beyondwords\Wordpress\Core\CoreUtils;
11
12 /**
13 * @SuppressWarnings("unused")
14 * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
15 **/
16 class Core
17 {
18 /**
19 * API Client
20 */
21 private $apiClient;
22
23 /**
24 * Constructor
25 *
26 * @since 3.0.0
27 * @since 3.7.1 Remove the "X BeyondWords errors found" notice after a reported slow MySQL query.
28 * @since 3.9.0 Add actions for deleting/trashing/restoring posts.
29 */
30 public function __construct($apiClient)
31 {
32 $this->apiClient = $apiClient;
33
34 // Actions
35 add_action('enqueue_block_editor_assets', array($this, 'enqueueBlockEditorAssets'), 1, 0);
36 add_action('init', array($this, 'loadPluginTextdomain'));
37 add_action('init', array($this, 'registerMeta'), 99, 3);
38
39 // Actions for adding/updating posts
40 add_action('wp_after_insert_post', array($this, 'onAddOrUpdatePost'), 99, 4);
41
42 // Actions for deleting/trashing/restoring posts
43 add_action('before_delete_post', array($this, 'onTrashOrDeletePost'));
44 add_action('trashed_post', array($this, 'onTrashOrDeletePost'));
45 add_action('untrashed_post', array($this, 'onUntrashPost'), 10, 2);
46
47 // Actions for WPGraphQL
48 add_action('graphql_register_types', array($this, 'graphqlRegisterTypes'));
49
50 // first hook we're filtering, second our callback, third priority, fourth # of parameters
51 add_filter('is_protected_meta', array($this, 'isProtectedMeta'), 10, 3);
52 }
53
54 /**
55 * Should process post status?
56 *
57 * @since 3.5.0
58 * @since 3.7.0 Process audio for posts with 'pending' status
59 *
60 * @param string $status WordPress post status (e.g. 'pending', 'publish', 'private', 'future', etc).
61 *
62 * @return boolean
63 */
64 public function shouldProcessPostStatus($status)
65 {
66 $statuses = ['pending', 'publish', 'private', 'future'];
67
68 /**
69 * Filters the post statuses that we consider for audio processing.
70 *
71 * When a post is saved with any other post status we will not send
72 * any data to the BeyondWords API.
73 *
74 * The default values are "pending", "publish", "private" and "future".
75 *
76 * Scheduled for removal in plugin version 5.0.0.
77 *
78 * @since 3.3.3
79 * @since 3.7.0 Process audio for posts with 'pending' status
80 *
81 * @deprecated 4.3.0 Replaced with beyondwords_settings_post_statuses.
82 *
83 * @param string[] $statuses The post statuses that we consider for audio processing.
84 */
85 $statuses = apply_filters('beyondwords_post_statuses', $statuses);
86
87 /**
88 * Filters the post statuses that we consider for audio processing.
89 *
90 * When a post is saved with any other post status we will not send
91 * any data to the BeyondWords API.
92 *
93 * The default values are "pending", "publish", "private" and "future".
94 *
95 * @since 3.3.3 Introduced as beyondwords_post_statuses.
96 * @since 3.7.0 Process audio for posts with 'pending' status.
97 * @since 4.3.0 Renamed from beyondwords_post_statuses to beyondwords_settings_post_statuses.
98 *
99 * @param string[] $statuses The post statuses that we consider for audio processing.
100 */
101 $statuses = apply_filters('beyondwords_settings_post_statuses', $statuses);
102
103 // Only generate audio for certain post statuses
104 if (is_array($statuses) && in_array($status, $statuses)) {
105 return true;
106 }
107
108 return false;
109 }
110
111 /**
112 * Should generate audio for post?
113 *
114 * @since 3.5.0
115 * @since 3.10.0 remove wp_is_post_revision check.
116 *
117 * @param int $postId WordPress Post ID.
118 *
119 * @return boolean
120 */
121 public function shouldGenerateAudioForPost($postId)
122 {
123 // Bail if this is an autosave
124 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
125 return false;
126 }
127
128 $status = get_post_status($postId);
129
130 // Bail if the post status is invalid
131 if (! $this->shouldProcessPostStatus($status)) {
132 return false;
133 }
134
135 $generateAudio = PostMetaUtils::hasGenerateAudio($postId);
136
137 // Bail if 'Generate Audio' has not been selected
138 if (! $generateAudio) {
139 return false;
140 }
141
142 return true;
143 }
144
145 /**
146 * Generate audio for post.
147 *
148 * @since 3.0.0
149 * @since 3.2.0 Added speechkit_post_statuses filter
150 * @since 3.5.0 Refactored, adding $this->shouldGenerateAudioForPost()
151 *
152 * @param int $postId WordPress Post ID.
153 *
154 * @return array|false Response from API, or false if audio was not generated.
155 */
156 public function generateAudioForPost($postId)
157 {
158 // Perform checks to see if this post should be processed
159 if (! $this->shouldGenerateAudioForPost($postId)) {
160 return false;
161 }
162
163 $projectId = PostMetaUtils::getProjectId($postId);
164
165 // Bail if we cannot determine a Project ID
166 if (! $projectId) {
167 return false;
168 }
169
170 // Does this post already have audio?
171 $contentId = PostMetaUtils::getContentId($postId);
172
173 // Has autoregeneration for Post updates been disabled?
174 if ($contentId) {
175 if (defined('BEYONDWORDS_AUTOREGENERATE') && ! BEYONDWORDS_AUTOREGENERATE) {
176 return false;
177 }
178
179 $response = $this->getApiClient()->updateAudio($postId);
180 } else {
181 $response = $this->getApiClient()->createAudio($postId);
182 }
183
184 $this->processResponse($response, $projectId, $postId);
185
186 return $response;
187 }
188
189 /**
190 * Delete audio for post.
191 *
192 * @since 4.0.5
193 *
194 * @param int $postId WordPress Post ID.
195 *
196 * @return array|false Response from API, or false if audio was not generated.
197 */
198 public function deleteAudioForPost($postId)
199 {
200 $projectId = PostMetaUtils::getProjectId($postId);
201 $contentId = PostMetaUtils::getContentId($postId);
202
203 // Bail if we cannot determine a Project ID or Content ID
204 if (! $projectId || ! $contentId) {
205 return false;
206 }
207
208 return $this->getApiClient()->deleteAudio($postId);
209 }
210
211 /**
212 * Batch delete audio for posts.
213 *
214 * @since 4.1.0
215 *
216 * @param int[] $postIds Array of WordPress Post IDs.
217 *
218 * @return array|false Response from API, or false if audio was not generated.
219 */
220 public function batchDeleteAudioForPosts($postIds)
221 {
222 return $this->getApiClient()->batchDeleteAudio($postIds);
223 }
224
225 /**
226 * Process the response body of a BeyondWords REST API response.
227 *
228 * @since 3.0.0
229 * @since 3.7.0 Stop saving response.access_key, we don't use it.
230 * @since 4.0.0 Replace Podcast IDs with Content IDs
231 */
232 public function processResponse($response, $projectId, $postId)
233 {
234 if (! is_array($response)) {
235 return $response;
236 }
237
238 if (array_key_exists('id', $response)) {
239 // Save Project ID
240 update_post_meta($postId, 'beyondwords_project_id', $projectId);
241
242 // Save Content ID
243 update_post_meta($postId, 'beyondwords_content_id', $response['id']);
244
245 // Temporarily save into Podcast ID field to support downgrades to < 4.0.0
246 update_post_meta($postId, 'beyondwords_podcast_id', $response['id']);
247 }
248
249 return $response;
250 }
251
252 /**
253 * Enqueue Core (built & minified) JS for Block Editor.
254 */
255 public function enqueueBlockEditorAssets()
256 {
257 if (CoreUtils::isGutenbergPage()) {
258 $postType = get_post_type();
259
260 $postTypes = SettingsUtils::getSupportedPostTypes();
261
262 if (in_array($postType, $postTypes, true)) {
263 $assetFile = include BEYONDWORDS__PLUGIN_DIR . 'build/index.asset.php';
264
265 // Register the Block Editor JS
266 wp_enqueue_script(
267 'beyondwords-block-js',
268 BEYONDWORDS__PLUGIN_URI . 'build/index.js',
269 $assetFile['dependencies'],
270 $assetFile['version'],
271 true
272 );
273 }
274 }
275 }
276
277 /**
278 * Load plugin textdomain.
279 *
280 * @since 3.5.0
281 *
282 * @return void
283 */
284 public function loadPluginTextdomain()
285 {
286 load_plugin_textdomain('speechkit');
287 }
288
289 /**
290 * Register meta fields for REST API output.
291 *
292 * It is recommended to register meta keys for a specific combination
293 * of object type and object subtype.
294 *
295 * @since 2.5.0
296 * @since 3.9.0 Don't register speechkit_status - downgrades to plugin v2.x are no longer expected.
297 *
298 * @return void
299 **/
300 public function registerMeta()
301 {
302 $postTypes = SettingsUtils::getSupportedPostTypes();
303
304 if (is_array($postTypes)) {
305 $keys = CoreUtils::getPostMetaKeys('all');
306
307 foreach ($postTypes as $postType) {
308 $options = array(
309 'show_in_rest' => true,
310 'single' => true,
311 'type' => 'string',
312 'default' => '',
313 'object_subtype' => $postType,
314 'prepare_callback' => 'sanitize_text_field',
315 'sanitize_callback' => 'sanitize_text_field',
316 'auth_callback' => function () {
317 return current_user_can('edit_posts');
318 },
319 );
320
321 foreach ($keys as $key) {
322 register_meta('post', $key, $options);
323 }
324 }
325 }
326 }
327
328 /**
329 * Make all of our custom fields private, so they don't appear in the
330 * "Custom Fields" panel, which can cause conflicts for the Block Editor.
331 *
332 * https://github.com/WordPress/gutenberg/issues/23078
333 *
334 * @since 4.0.0
335 */
336 public function isProtectedMeta($protected, $metaKey, $metaType)
337 {
338 $keysToProtect = CoreUtils::getPostMetaKeys('all');
339
340 if (in_array($metaKey, $keysToProtect, true)) {
341 $protected = true;
342 }
343
344 return $protected;
345 }
346
347 /**
348 * WP Trash/Delete Post action.
349 *
350 * Fires before a post has been trashed or deleted.
351 *
352 * We want to send a DELETE HTTP request when a post is either trashed or deleted, so the
353 * audio no longer appears in playlists, or in the publishers BeyondWords dashboard.
354 *
355 * @since 3.9.0
356 *
357 * @param int $postId Post ID.
358 *
359 * @return bool
360 **/
361 public function onTrashOrDeletePost($postId)
362 {
363 // Bail if this post has no Project ID / Content ID
364 if (! PostMetaUtils::getProjectId($postId) || ! PostMetaUtils::getContentId($postId)) {
365 return false;
366 }
367
368 $response = $this->getApiClient()->deleteAudio($postId);
369
370 if (
371 ! is_array($response) ||
372 ! array_key_exists('deleted', $response) ||
373 ! $response['deleted'] === true
374 ) {
375 $errorMessage = __('Unable to delete audio from BeyondWords dashboard');
376
377 if (is_array($response) && array_key_exists('message', $response)) {
378 $errorMessage .= ': ' . $response['message'];
379 }
380
381 update_post_meta($postId, 'beyondwords_error_message', $errorMessage);
382
383 return false;
384 }
385
386 return $response;
387 }
388
389 /**
390 * WP Untrash ("Restore") Post action.
391 *
392 * Fires before a post is restored from the Trash.
393 *
394 * We want to send a PUT HTTP request when a post is Untrashed, to "undelete" it from the BeyondWords dashboard.
395 *
396 * @since 3.9.0
397 *
398 * @param int $postId Post ID.
399 * @param string $previousStatus The status of the post at the point where it was trashed.
400 *
401 * @return bool|Response
402 **/
403 public function onUntrashPost($postId, $previousStatus)
404 {
405 // Bail if this post has no Project ID / Content ID
406 if (! PostMetaUtils::getProjectId($postId) || ! PostMetaUtils::getContentId($postId)) {
407 return false;
408 }
409
410 $response = $this->getApiClient()->updateAudio($postId);
411
412 if (
413 ! is_array($response) ||
414 ! array_key_exists('id', $response) ||
415 ! array_key_exists('deleted', $response) ||
416 ! $response['deleted'] === false
417 ) {
418 $errorMessage = __('Unable to restore audio to BeyondWords dashboard');
419
420 if (is_array($response) && array_key_exists('message', $response)) {
421 $errorMessage .= ': ' . $response['message'];
422 }
423
424 update_post_meta($postId, 'beyondwords_error_message', $errorMessage);
425
426 return false;
427 }
428
429 return $response;
430 }
431
432 /**
433 * WP Save Post action.
434 *
435 * Fires after a post, its terms and meta data has been saved.
436 *
437 * @SuppressWarnings(PHPMD.UnusedFormalParameter)
438 *
439 * @since 3.0.0
440 * @since 3.2.0 Added beyondwords_post_statuses filter.
441 * @since 3.6.1 Improve $postBefore hash comparison.
442 * @since 3.9.0 Renamed method from wpAfterInsertPost to onAddOrUpdatePost.
443 * @since 4.0.0 Removed hash comparison.
444 * @since 4.4.0 Delete audio if beyondwords_delete_content custom field is set.
445 *
446 * @param int $postId Post ID.
447 * @param WP_Post $post Post object.
448 * @param bool $update Whether this is an existing post being updated.
449 * @param null|WP_Post $postBefore Null for new posts, the WP_Post object prior to the update for updated posts.
450 *
451 * @return bool|Response
452 **/
453 public function onAddOrUpdatePost($postId, $post, $update, $postBefore)
454 {
455 // Has the "Remove" feature been used?
456 if (get_post_meta($postId, 'beyondwords_delete_content', true) === '1') {
457 update_post_meta($postId, 'beyondwords_delete_content_STATUS', 'DOING');
458 // Make DELETE API request
459 $this->deleteAudioForPost($postId);
460
461 // Remove custom fields
462 PostMetaUtils::removeAllBeyondwordsMetadata($postId);
463
464 return false;
465 } else {
466 update_post_meta($postId, 'beyondwords_delete_content_STATUS', 'SKIPPING');
467 }
468
469 $status = get_post_status($postId);
470
471 // Bail if the post status is invalid
472 if (! $this->shouldProcessPostStatus($status)) {
473 return false;
474 }
475
476 // Generate Audio for the updated post
477 $this->generateAudioForPost($postId);
478
479 return true;
480 }
481
482 /**
483 * "X BeyondWords errors found" notice.
484 *
485 * THIS HAS BEEN TEMPORARILY REMOVED FROM THE POSTS PAGE, after a
486 * report of a slow MySQL query. Errors are still presented in the
487 * BeyondWords column (Posts screen) and BeyondWords panel (Post Edit screen).
488 *
489 * @todo consider showing a detailed list of errors in Tools > Site Health.
490 *
491 * @since 3.0.0
492 * @since 3.7.0 Query BOTH speechkit_error_message and beyondwords_error_message.
493 * @since 3.7.1 Query ONLY beyondwords_error_message to fix a reported slow MySQL query.
494 */
495 public function postsWithErrorsNotice()
496 {
497 $screen = get_current_screen();
498
499 $postTypes = SettingsUtils::getSupportedPostTypes();
500
501 if ($screen->id !== "edit-{$screen->post_type}" || ! in_array($screen->post_type, $postTypes)) {
502 return;
503 }
504
505 // Count posts with BeyondWords Errors
506 // meta_query EXISTS is NOT expensive, see https://github.com/WordPress/WordPress-Coding-Standards/issues/1871.
507 $query = new \WP_Query([
508 'post_type' => $screen->post_type,
509 'posts_per_page' => -1,
510 'meta_query' => [ // phpcs:ignore
511 'key' => 'beyondwords_error_message',
512 'compare' => 'EXISTS',
513 ],
514 ]);
515
516 $errorCount = $query->post_count;
517
518 if ($errorCount) {
519 $type = 'notice-error';
520 $errorMessage = sprintf(
521 /* translators: %d is replaced with number of BeyondWords errors */
522 _n(
523 '%d BeyondWords error found.',
524 '%d BeyondWords errors found.',
525 $errorCount,
526 'speechkit'
527 ),
528 $errorCount
529 );
530 ?>
531 <div id="beyondwords-bulk-edit-result" class="notice <?php echo esc_attr($type); ?>">
532 <p><?php echo esc_html($errorMessage); ?></p>
533 <p><?php _e('Check the BeyondWords column for more details.', 'speechkit'); ?></p>
534 </div>
535 <?php
536 }
537 }
538
539 public function getApiClient()
540 {
541 return $this->apiClient;
542 }
543
544 /**
545 * GraphQL: Register types.
546 *
547 * @since 3.6.0
548 * @since 4.0.0 Register contentId field, and contentId/podcastId are now String, not Int
549 */
550 public function graphqlRegisterTypes()
551 {
552 register_graphql_object_type('Beyondwords', [
553 'description' => __('BeyondWords audio details. Use this data to embed an audio player using the BeyondWords JavaScript SDK.', 'speechkit'), // phpcs:ignore Generic.Files.LineLength.TooLong
554 'fields' => [
555 'projectId' => [
556 'description' => __('BeyondWords project ID', 'speechkit'),
557 'type' => 'Int'
558 ],
559 'contentId' => [
560 'description' => __('BeyondWords content ID', 'speechkit'),
561 'type' => 'String'
562 ],
563 'podcastId' => [
564 'description' => __('BeyondWords legacy podcast ID', 'speechkit'),
565 'type' => 'String'
566 ],
567 ],
568 ]);
569
570 $beyondwordsPostTypes = SettingsUtils::getSupportedPostTypes();
571
572 $graphqlPostTypes = \WPGraphQL::get_allowed_post_types();
573
574 $postTypes = array_intersect($beyondwordsPostTypes, $graphqlPostTypes);
575
576 if (! empty($postTypes) && is_array($postTypes)) {
577 foreach ($postTypes as $postType) {
578 $postTypeObject = get_post_type_object($postType);
579
580 register_graphql_field($postTypeObject->graphql_single_name, 'beyondwords', [
581 'type' => 'Beyondwords',
582 'description' => __('BeyondWords audio details', 'speechkit'),
583 'resolve' => function (\WPGraphQL\Model\Post $post) {
584 $beyondwords = [];
585
586 $contentId = PostMetaUtils::getContentId($post->ID);
587
588 if (! empty($contentId)) {
589 $beyondwords['contentId'] = $contentId;
590 $beyondwords['podcastId'] = $contentId; // legacy
591 }
592
593 $projectId = PostMetaUtils::getProjectId($post->ID);
594
595 if (! empty($projectId)) {
596 $beyondwords['projectId'] = $projectId;
597 }
598
599 return ! empty($beyondwords) ? $beyondwords : null;
600 }
601 ]);
602 }
603 }
604 }
605 }
606