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