PluginProbe
BeyondWords – AI audio for publishers / 4.5.0
BeyondWords – AI audio for publishers v4.5.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.5.0, at src/Core/Core.php

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