Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.31% covered (success)
94.31%
116 / 123
73.33% covered (warning)
73.33%
11 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Core
94.31% covered (success)
94.31%
116 / 123
73.33% covered (warning)
73.33%
11 / 15
51.48
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 shouldProcessPostStatus
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 shouldGenerateAudioForPost
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 generateAudioForPost
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
7.05
 updateOrRecreateAudio
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 deleteAudioForPost
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 batchDeleteAudioForPosts
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 processResponse
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
9
 enqueueBlockEditorAssets
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 registerMeta
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 isProtectedMeta
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 onTrashPost
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 onDeletePost
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 onAddOrUpdatePost
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
3.47
 getLangCodeFromJsonIfEmpty
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2
3declare(strict_types=1);
4
5namespace Beyondwords\Wordpress\Core;
6
7use Beyondwords\Wordpress\Component\Post\PostMetaUtils;
8use Beyondwords\Wordpress\Component\Settings\Fields\IntegrationMethod\IntegrationMethod;
9use Beyondwords\Wordpress\Component\Settings\SettingsUtils;
10use Beyondwords\Wordpress\Core\CoreUtils;
11
12defined('ABSPATH') || exit;
13
14class 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}