PluginProbe
BeyondWords – AI audio for publishers / 4.1.1
BeyondWords – AI audio for publishers v4.1.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.1.1, at src/Core/Core.php

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