PluginProbe
BeyondWords – AI audio for publishers / 6.2.0
BeyondWords – AI audio for publishers v6.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 4.7.0 All 43 releases
speechkit / src / Core / Core.php

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

491 lines 16.6 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\Fields\IntegrationMethod\IntegrationMethod;
9 use Beyondwords\Wordpress\Component\Settings\SettingsUtils;
10 use Beyondwords\Wordpress\Core\CoreUtils;
11
12 defined('ABSPATH') || exit;
13
14 class Core
15 {
16 /**
17 * Init.
18 *
19 * @since 4.0.0
20 * @since 6.0.0 Make static and stop loading plugin text domain on init.
21 */
22 public static function init(): void
23 {
24 // Actions
25 add_action('enqueue_block_editor_assets', [self::class, 'enqueueBlockEditorAssets'], 1, 0);
26 add_action('init', [self::class, 'registerMeta'], 99, 3);
27
28 // Actions for adding/updating posts
29 add_action('wp_after_insert_post', [self::class, 'onAddOrUpdatePost'], 99);
30
31 // Actions for trashing/deleting posts
32 add_action('wp_trash_post', [self::class, 'onTrashPost']);
33 add_action('before_delete_post', [self::class, 'onDeletePost']);
34
35 add_filter('is_protected_meta', [self::class, 'isProtectedMeta'], 10, 2);
36
37 // Older posts may be missing beyondwords_language_code, so we'll try to set it.
38 add_filter('get_post_metadata', [self::class, 'getLangCodeFromJsonIfEmpty'], 10, 3);
39 }
40
41 /**
42 * Should process post status?
43 *
44 * @since 3.5.0
45 * @since 3.7.0 Process audio for posts with 'pending' status
46 * @since 5.0.0 Remove beyondwords_post_statuses filter.
47 * @since 6.0.0 Make static.
48 *
49 * @param string $status WordPress post status (e.g. 'pending', 'publish', 'private', 'future', etc).
50 */
51 public static function shouldProcessPostStatus(string $status): bool
52 {
53 $statuses = ['pending', 'publish', 'private', 'future'];
54
55 /**
56 * Filters the post statuses that we consider for audio processing.
57 *
58 * When a post is saved with any other post status we will not send
59 * any data to the BeyondWords API.
60 *
61 * The default values are "pending", "publish", "private" and "future".
62 *
63 * @since 3.3.3 Introduced as beyondwords_post_statuses.
64 * @since 3.7.0 Process audio for posts with 'pending' status.
65 * @since 4.3.0 Renamed from beyondwords_post_statuses to beyondwords_settings_post_statuses.
66 *
67 * @param string[] $statuses The post statuses that we consider for audio processing.
68 */
69 $statuses = apply_filters('beyondwords_settings_post_statuses', $statuses);
70
71 return is_array($statuses) && in_array($status, $statuses);
72 }
73
74 /**
75 * Should generate audio for post?
76 *
77 * @since 3.5.0
78 * @since 3.10.0 Remove wp_is_post_revision check
79 * @since 5.1.0 Regenerate audio for all post statuses
80 * @since 6.0.0 Make static, ignore revisions, refactor status
81 * checks, and add support Magic Embed support.
82 *
83 * @param int $postId WordPress Post ID.
84 */
85 public static function shouldGenerateAudioForPost(int $postId): bool
86 {
87 // Ignore autosaves and revisions
88 if (wp_is_post_autosave($postId) || wp_is_post_revision($postId)) {
89 return false;
90 }
91
92 $status = get_post_status($postId);
93
94 // Only (re)generate audio for certain post statuses.
95 if (! self::shouldProcessPostStatus($status)) {
96 return false;
97 }
98
99 // Generate if the "Generate audio" custom field is set.
100 if (PostMetaUtils::hasGenerateAudio($postId)) {
101 return (bool) get_post_meta($postId, 'beyondwords_generate_audio', true);
102 }
103
104 return false;
105 }
106
107 /**
108 * Generate audio for a post if certain conditions are met.
109 *
110 * @since 3.0.0
111 * @since 3.2.0 Added speechkit_post_statuses filter
112 * @since 3.5.0 Refactored, adding self::shouldGenerateAudioForPost()
113 * @since 5.1.0 Move project ID check into self::shouldGenerateAudioForPost()
114 * @since 6.0.0 Make static and support Magic Embed.
115 * @since 6.0.5 If the audio update request 404s, clear the stale content ID and create new audio content.
116 *
117 * @param int $postId WordPress Post ID.
118 *
119 * @return array|false|null Response from API, or false if audio was not generated.
120 */
121 public static function generateAudioForPost(int $postId): array|false|null
122 {
123 // Perform checks to see if this post should be processed
124 if (! self::shouldGenerateAudioForPost($postId)) {
125 return false;
126 }
127
128 $post = get_post($postId);
129 if (! $post) {
130 return false;
131 }
132
133 $integrationMethod = IntegrationMethod::getIntegrationMethod($post);
134
135 // For Magic Embed we call the "get_player_by_source_id" endpoint to import content.
136 if (IntegrationMethod::CLIENT_SIDE === $integrationMethod) {
137 // Save the integration method & Project ID.
138 update_post_meta($postId, 'beyondwords_integration_method', IntegrationMethod::CLIENT_SIDE);
139 update_post_meta($postId, 'beyondwords_project_id', get_option('beyondwords_project_id'));
140
141 return ApiClient::getPlayerBySourceId($postId);
142 }
143
144 // For non-Magic Embed we use the REST API to generate audio.
145 update_post_meta($postId, 'beyondwords_integration_method', IntegrationMethod::REST_API);
146
147 // Does this post already have audio?
148 $contentId = PostMetaUtils::getContentId($postId);
149
150 // Has autoregeneration for Post updates been disabled?
151 if ($contentId) {
152 if (defined('BEYONDWORDS_AUTOREGENERATE') && ! BEYONDWORDS_AUTOREGENERATE) {
153 return false;
154 }
155
156 $response = self::updateOrRecreateAudio($postId);
157 } else {
158 $response = ApiClient::createAudio($postId);
159 }
160
161 $projectId = PostMetaUtils::getProjectId($postId);
162
163 self::processResponse($response, $projectId, $postId);
164
165 return $response;
166 }
167
168 /**
169 * Update audio for a post, recovering from 404 by creating fresh content.
170 *
171 * When the BeyondWords API returns 404 for an update (content no longer
172 * exists), this method clears the stale content ID and falls back to
173 * creating new content via POST.
174 *
175 * @since 6.0.5
176 *
177 * @param int $postId WordPress Post ID.
178 *
179 * @return array|null|false Response from API.
180 */
181 private static function updateOrRecreateAudio(int $postId): array|null|false
182 {
183 $response = ApiClient::updateAudio($postId);
184
185 // If the API returned 404, the content no longer exists.
186 // Clear the stale ID and create fresh content.
187 // We check the error message that callApi() saves using the HTTP
188 // status code, rather than parsing the response body format.
189 $errorMessage = (string) get_post_meta($postId, 'beyondwords_error_message', true);
190
191 if (str_starts_with($errorMessage, '#404:')) {
192 delete_post_meta($postId, 'beyondwords_content_id');
193 delete_post_meta($postId, 'beyondwords_podcast_id');
194 delete_post_meta($postId, 'speechkit_podcast_id');
195
196 $response = ApiClient::createAudio($postId);
197 }
198
199 return $response;
200 }
201
202 /**
203 * Delete audio for post.
204 *
205 * @since 4.0.5
206 * @since 6.0.0 Make static.
207 *
208 * @param int $postId WordPress Post ID.
209 *
210 * @return array|false|null Response from API, or false if audio was not generated.
211 */
212 public static function deleteAudioForPost(int $postId): array|false|null
213 {
214 return ApiClient::deleteAudio($postId);
215 }
216
217 /**
218 * Batch delete audio for posts.
219 *
220 * @since 4.1.0
221 * @since 6.0.0 Make static.
222 *
223 * @param int[] $postIds Array of WordPress Post IDs.
224 *
225 * @return array|false Response from API, or false if audio was not generated.
226 */
227 public static function batchDeleteAudioForPosts(array $postIds): array|false|null
228 {
229 return ApiClient::batchDeleteAudio($postIds);
230 }
231
232 /**
233 * Process the response body of a BeyondWords REST API response.
234 *
235 * @since 3.0.0
236 * @since 3.7.0 Stop saving response.access_key, we don't currently use it.
237 * @since 4.0.0 Replace Podcast IDs with Content IDs
238 * @since 4.5.0 Save response.preview_token to support post scheduling.
239 * @since 5.0.0 Stop saving `beyondwords_podcast_id`.
240 * @since 6.0.0 Make static.
241 */
242 public static function processResponse(mixed $response, int|string|false $projectId, int $postId): mixed
243 {
244 if (! is_array($response)) {
245 return $response;
246 }
247
248 if ($projectId && ! empty($response['id'])) {
249 update_post_meta($postId, 'beyondwords_project_id', $projectId);
250 update_post_meta($postId, 'beyondwords_content_id', $response['id']);
251
252 if (! empty($response['preview_token'])) {
253 update_post_meta($postId, 'beyondwords_preview_token', $response['preview_token']);
254 }
255
256 if (! empty($response['language'])) {
257 update_post_meta($postId, 'beyondwords_language_code', $response['language']);
258 }
259
260 if (! empty($response['title_voice_id'])) {
261 update_post_meta($postId, 'beyondwords_title_voice_id', $response['title_voice_id']);
262 }
263
264 if (! empty($response['summary_voice_id'])) {
265 update_post_meta($postId, 'beyondwords_summary_voice_id', $response['summary_voice_id']);
266 }
267
268 if (! empty($response['body_voice_id'])) {
269 update_post_meta($postId, 'beyondwords_body_voice_id', $response['body_voice_id']);
270 }
271 }
272
273 return $response;
274 }
275
276 /**
277 * Enqueue Core (built & minified) JS for Block Editor.
278 *
279 * @since 3.0.0
280 * @since 4.5.1 Disable plugin features if we don't have valid API settings.
281 * @since 6.0.0 Make static.
282 */
283 public static function enqueueBlockEditorAssets()
284 {
285 if (! SettingsUtils::hasValidApiConnection()) {
286 return;
287 }
288
289 $postType = get_post_type();
290
291 $postTypes = SettingsUtils::getCompatiblePostTypes();
292
293 if (in_array($postType, $postTypes, true)) {
294 $assetFile = include BEYONDWORDS__PLUGIN_DIR . 'build/index.asset.php';
295
296 // Register the Block Editor JS
297 wp_enqueue_script(
298 'beyondwords-block-js',
299 BEYONDWORDS__PLUGIN_URI . 'build/index.js',
300 $assetFile['dependencies'],
301 $assetFile['version'],
302 true
303 );
304 }
305 }
306
307 /**
308 * Register meta fields for REST API output.
309 *
310 * It is recommended to register meta keys for a specific combination
311 * of object type and object subtype.
312 *
313 * @since 2.5.0
314 * @since 3.9.0 Don't register speechkit_status - downgrades to plugin v2.x are no longer expected.
315 * @since 6.0.0 Make static.
316 **/
317 public static function registerMeta()
318 {
319 $postTypes = SettingsUtils::getCompatiblePostTypes();
320
321 if (is_array($postTypes)) {
322 $keys = CoreUtils::getPostMetaKeys('all');
323
324 foreach ($postTypes as $postType) {
325 $options = [
326 'show_in_rest' => true,
327 'single' => true,
328 'type' => 'string',
329 'default' => '',
330 'object_subtype' => $postType,
331 'prepare_callback' => 'sanitize_text_field',
332 'sanitize_callback' => 'sanitize_text_field',
333 'auth_callback' => fn(): bool => current_user_can('edit_posts'),
334 ];
335
336 foreach ($keys as $key) {
337 register_meta('post', $key, $options);
338 }
339 }
340 }
341 }
342
343 /**
344 * Make all of our custom fields private, so they don't appear in the
345 * "Custom Fields" panel, which can cause conflicts for the Block Editor.
346 *
347 * https://github.com/WordPress/gutenberg/issues/23078
348 *
349 * @since 4.0.0
350 * @since 6.0.0 Make static.
351 * @since 6.0.1 Accept null params from WP core.
352 */
353 public static function isProtectedMeta($protected, $metaKey)
354 {
355 if ($metaKey === null) {
356 return (bool) $protected;
357 }
358
359 $keysToProtect = CoreUtils::getPostMetaKeys('all');
360
361 if (in_array($metaKey, $keysToProtect, true)) {
362 return true;
363 }
364
365 return (bool) $protected;
366 }
367
368 /**
369 * On trash post.
370 *
371 * We attempt to send a DELETE REST API request when a post is trashed so the audio
372 * no longer appears in playlists, or in the publishers BeyondWords dashboard.
373 *
374 * @since 3.9.0 Introduced.
375 * @since 5.4.0 Renamed from onTrashOrDeletePost, and we now remove all
376 * BeyondWords data when a post is trashed.
377 * @since 6.0.0 Make static.
378 *
379 * @param int $postId Post ID.
380 **/
381 public static function onTrashPost($postId)
382 {
383 $postId = (int) $postId;
384
385 // Skip posts that don't have BeyondWords content (e.g. revisions, Jetpack sitemaps)
386 if (! PostMetaUtils::hasContent($postId)) {
387 return;
388 }
389
390 ApiClient::deleteAudio($postId);
391 PostMetaUtils::removeAllBeyondwordsMetadata($postId);
392 }
393
394 /**
395 * On delete post.
396 *
397 * We attempt to send a DELETE REST API request when a post is deleted so the audio
398 * no longer appears in playlists, or in the publishers BeyondWords dashboard.
399 *
400 * @since 5.4.0 Introduced, replacing onTrashOrDeletePost.
401 * @since 6.0.0 Make static.
402 *
403 * @param int $postId Post ID.
404 **/
405 public static function onDeletePost($postId)
406 {
407 $postId = (int) $postId;
408
409 // Skip posts that don't have BeyondWords content (e.g. revisions, Jetpack sitemaps)
410 if (! PostMetaUtils::hasContent($postId)) {
411 return;
412 }
413
414 ApiClient::deleteAudio($postId);
415 }
416
417 /**
418 * WP Save Post action.
419 *
420 * Fires after a post, its terms and meta data has been saved.
421 *
422 * @since 3.0.0
423 * @since 3.2.0 Added beyondwords_post_statuses filter.
424 * @since 3.6.1 Improve $postBefore hash comparison.
425 * @since 3.9.0 Renamed method from wpAfterInsertPost to onAddOrUpdatePost.
426 * @since 4.0.0 Removed hash comparison.
427 * @since 4.4.0 Delete audio if beyondwords_delete_content custom field is set.
428 * @since 4.5.0 Remove unwanted debugging custom fields.
429 * @since 5.1.0 Move post status check out of here.
430 * @since 6.0.0 Make static and refactor for Magic Embed updates.
431 * @since 6.0.5 Skip second wp_after_insert_post triggered by Gutenberg's meta box save.
432 *
433 * @param int $postId Post ID.
434 **/
435 public static function onAddOrUpdatePost($postId)
436 {
437 $postId = (int) $postId;
438
439 // Gutenberg fires wp_after_insert_post twice: once via the REST API,
440 // and again via a backward-compatible meta box save POST to post.php.
441 // Skip the second (redundant) request to prevent duplicate API calls.
442 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
443 if (! empty($_REQUEST['meta-box-loader'])) {
444 return false;
445 }
446
447 // Has the "Remove" feature been used?
448 if (get_post_meta($postId, 'beyondwords_delete_content', true) === '1') {
449 // Make DELETE API request
450 self::deleteAudioForPost($postId);
451
452 // Remove custom fields
453 PostMetaUtils::removeAllBeyondwordsMetadata($postId);
454
455 return false;
456 }
457
458 return (bool) self::generateAudioForPost($postId);
459 }
460
461 /**
462 * Get the language code from a JSON mapping if it is empty.
463 *
464 * @since 5.4.0 Introduced.
465 * @since 6.0.0 Make static.
466 * @since 6.0.1 Accept a null $meta_key parameter.
467 *
468 * @param mixed $value The value of the metadata.
469 * @param int $object_id The ID of the object metadata is for.
470 * @param ?string $meta_key The key of the metadata.
471 *
472 * @return mixed The metadata value.
473 */
474 public static function getLangCodeFromJsonIfEmpty($value, $object_id, $meta_key)
475 {
476 if ('beyondwords_language_code' === $meta_key && empty($value)) {
477 $languageId = get_post_meta($object_id, 'beyondwords_language_id', true);
478
479 if ($languageId) {
480 $langCodes = json_decode(file_get_contents(BEYONDWORDS__PLUGIN_DIR . 'assets/lang-codes.json'), true);
481
482 if (is_array($langCodes) && array_key_exists($languageId, $langCodes)) {
483 return [$langCodes[$languageId]];
484 }
485 }
486 }
487
488 return $value;
489 }
490 }
491