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

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

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