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