PluginProbe
BeyondWords – AI audio for publishers / 4.6.0
BeyondWords – AI audio for publishers v4.6.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.6.0, at src/Core/Core.php

563 lines 17.3 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 * @since 3.0.0
270 * @since 4.5.1 Disable plugin features if we don't have valid API settings.
271 */
272 public function enqueueBlockEditorAssets()
273 {
274 if (! SettingsUtils::hasApiSettings()) {
275 return;
276 }
277
278 if (CoreUtils::isGutenbergPage()) {
279 $postType = get_post_type();
280
281 $postTypes = SettingsUtils::getCompatiblePostTypes();
282
283 if (in_array($postType, $postTypes, true)) {
284 $assetFile = include BEYONDWORDS__PLUGIN_DIR . 'build/index.asset.php';
285
286 // Register the Block Editor JS
287 wp_enqueue_script(
288 'beyondwords-block-js',
289 BEYONDWORDS__PLUGIN_URI . 'build/index.js',
290 $assetFile['dependencies'],
291 $assetFile['version'],
292 true
293 );
294 }
295 }
296 }
297
298 /**
299 * Load plugin textdomain.
300 *
301 * @since 3.5.0
302 *
303 * @return void
304 */
305 public function loadPluginTextdomain()
306 {
307 load_plugin_textdomain('speechkit');
308 }
309
310 /**
311 * Register meta fields for REST API output.
312 *
313 * It is recommended to register meta keys for a specific combination
314 * of object type and object subtype.
315 *
316 * @since 2.5.0
317 * @since 3.9.0 Don't register speechkit_status - downgrades to plugin v2.x are no longer expected.
318 *
319 * @return void
320 **/
321 public function registerMeta()
322 {
323 $postTypes = SettingsUtils::getCompatiblePostTypes();
324
325 if (is_array($postTypes)) {
326 $keys = CoreUtils::getPostMetaKeys('all');
327
328 foreach ($postTypes as $postType) {
329 $options = array(
330 'show_in_rest' => true,
331 'single' => true,
332 'type' => 'string',
333 'default' => '',
334 'object_subtype' => $postType,
335 'prepare_callback' => 'sanitize_text_field',
336 'sanitize_callback' => 'sanitize_text_field',
337 'auth_callback' => function () {
338 return current_user_can('edit_posts');
339 },
340 );
341
342 foreach ($keys as $key) {
343 register_meta('post', $key, $options);
344 }
345 }
346 }
347 }
348
349 /**
350 * Make all of our custom fields private, so they don't appear in the
351 * "Custom Fields" panel, which can cause conflicts for the Block Editor.
352 *
353 * https://github.com/WordPress/gutenberg/issues/23078
354 *
355 * @since 4.0.0
356 */
357 public function isProtectedMeta($protected, $metaKey)
358 {
359 $keysToProtect = CoreUtils::getPostMetaKeys('all');
360
361 if (in_array($metaKey, $keysToProtect, true)) {
362 $protected = true;
363 }
364
365 return $protected;
366 }
367
368 /**
369 * WP Trash/Delete Post action.
370 *
371 * Fires before a post has been trashed or deleted.
372 *
373 * We want to send a DELETE HTTP request when a post is either trashed or deleted, so the
374 * audio no longer appears in playlists, or in the publishers BeyondWords dashboard.
375 *
376 * @since 3.9.0
377 *
378 * @param int $postId Post ID.
379 *
380 * @return bool
381 **/
382 public function onTrashOrDeletePost($postId)
383 {
384 // Bail if this post has no Project ID / Content ID
385 if (! PostMetaUtils::getProjectId($postId) || ! PostMetaUtils::getContentId($postId)) {
386 return false;
387 }
388
389 $response = $this->apiClient->deleteAudio($postId);
390
391 if (
392 ! is_array($response) ||
393 ! array_key_exists('deleted', $response) ||
394 ! $response['deleted'] === true
395 ) {
396 $errorMessage = __('Unable to delete audio from BeyondWords dashboard');
397
398 if (is_array($response) && array_key_exists('message', $response)) {
399 $errorMessage .= ': ' . $response['message'];
400 }
401
402 update_post_meta($postId, 'beyondwords_error_message', $errorMessage);
403
404 return false;
405 }
406
407 return $response;
408 }
409
410 /**
411 * WP Untrash ("Restore") Post action.
412 *
413 * Fires before a post is restored from the Trash.
414 *
415 * We want to send a PUT HTTP request when a post is Untrashed, to "undelete" it from the BeyondWords dashboard.
416 *
417 * @since 3.9.0
418 *
419 * @param int $postId Post ID.
420 * @param string $previousStatus The status of the post at the point where it was trashed.
421 *
422 * @return bool|Response
423 **/
424 public function onUntrashPost($postId)
425 {
426 // Bail if this post has no Project ID / Content ID
427 if (! PostMetaUtils::getProjectId($postId) || ! PostMetaUtils::getContentId($postId)) {
428 return false;
429 }
430
431 $response = $this->apiClient->updateAudio($postId);
432
433 if (
434 ! is_array($response) ||
435 ! array_key_exists('id', $response) ||
436 ! array_key_exists('deleted', $response) ||
437 ! $response['deleted'] === false
438 ) {
439 $errorMessage = __('Unable to restore audio to BeyondWords dashboard');
440
441 if (is_array($response) && array_key_exists('message', $response)) {
442 $errorMessage .= ': ' . $response['message'];
443 }
444
445 update_post_meta($postId, 'beyondwords_error_message', $errorMessage);
446
447 return false;
448 }
449
450 return $response;
451 }
452
453 /**
454 * WP Save Post action.
455 *
456 * Fires after a post, its terms and meta data has been saved.
457 *
458 * @since 3.0.0
459 * @since 3.2.0 Added beyondwords_post_statuses filter.
460 * @since 3.6.1 Improve $postBefore hash comparison.
461 * @since 3.9.0 Renamed method from wpAfterInsertPost to onAddOrUpdatePost.
462 * @since 4.0.0 Removed hash comparison.
463 * @since 4.4.0 Delete audio if beyondwords_delete_content custom field is set.
464 * @since 4.5.0 Remove unwanted debugging custom fields.
465 *
466 * @param int $postId Post ID.
467 *
468 * @return bool|Response
469 **/
470 public function onAddOrUpdatePost($postId)
471 {
472 // Has the "Remove" feature been used?
473 if (get_post_meta($postId, 'beyondwords_delete_content', true) === '1') {
474 // Make DELETE API request
475 $this->deleteAudioForPost($postId);
476
477 // Remove custom fields
478 PostMetaUtils::removeAllBeyondwordsMetadata($postId);
479
480 return false;
481 }
482
483 $status = get_post_status($postId);
484
485 // Bail if the post status is invalid
486 if (! $this->shouldProcessPostStatus($status)) {
487 return false;
488 }
489
490 // Generate Audio for the updated post
491 $this->generateAudioForPost($postId);
492
493 return true;
494 }
495
496 public function getApiClient()
497 {
498 return $this->apiClient;
499 }
500
501 /**
502 * GraphQL: Register types.
503 *
504 * @since 3.6.0
505 * @since 4.0.0 Register contentId field, and contentId/podcastId are now String, not Int
506 */
507 public function graphqlRegisterTypes()
508 {
509 register_graphql_object_type('Beyondwords', [
510 'description' => __('BeyondWords audio details. Use this data to embed an audio player using the BeyondWords JavaScript SDK.', 'speechkit'), // phpcs:ignore Generic.Files.LineLength.TooLong
511 'fields' => [
512 'projectId' => [
513 'description' => __('BeyondWords project ID', 'speechkit'),
514 'type' => 'Int'
515 ],
516 'contentId' => [
517 'description' => __('BeyondWords content ID', 'speechkit'),
518 'type' => 'String'
519 ],
520 'podcastId' => [
521 'description' => __('BeyondWords legacy podcast ID', 'speechkit'),
522 'type' => 'String'
523 ],
524 ],
525 ]);
526
527 $beyondwordsPostTypes = SettingsUtils::getCompatiblePostTypes();
528
529 $graphqlPostTypes = \WPGraphQL::get_allowed_post_types();
530
531 $postTypes = array_intersect($beyondwordsPostTypes, $graphqlPostTypes);
532
533 if (! empty($postTypes) && is_array($postTypes)) {
534 foreach ($postTypes as $postType) {
535 $postTypeObject = get_post_type_object($postType);
536
537 register_graphql_field($postTypeObject->graphql_single_name, 'beyondwords', [
538 'type' => 'Beyondwords',
539 'description' => __('BeyondWords audio details', 'speechkit'),
540 'resolve' => function (\WPGraphQL\Model\Post $post) {
541 $beyondwords = [];
542
543 $contentId = PostMetaUtils::getContentId($post->ID);
544
545 if (! empty($contentId)) {
546 $beyondwords['contentId'] = $contentId;
547 $beyondwords['podcastId'] = $contentId; // legacy
548 }
549
550 $projectId = PostMetaUtils::getProjectId($post->ID);
551
552 if (! empty($projectId)) {
553 $beyondwords['projectId'] = $projectId;
554 }
555
556 return ! empty($beyondwords) ? $beyondwords : null;
557 }
558 ]);
559 }
560 }
561 }
562 }
563