Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.16% covered (success)
85.16%
109 / 128
40.00% covered (danger)
40.00%
6 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
PostMetaUtils
85.16% covered (success)
85.16%
109 / 128
40.00% covered (danger)
40.00%
6 / 15
42.72
0.00% covered (danger)
0.00%
0 / 1
 getRenamedPostMeta
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getAllBeyondwordsMetadata
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
1
 removeAllBeyondwordsMetadata
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
2
 getContentId
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getPodcastId
72.22% covered (warning)
72.22%
13 / 18
0.00% covered (danger)
0.00%
0 / 1
9.37
 getPreviewToken
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 hasGenerateAudio
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
5.27
 getProjectId
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
6.03
 getBodyVoiceId
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getTitleVoiceId
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getSummaryVoiceId
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getPlayerStyle
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getErrorMessage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDisabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getHttpResponseBodyFromPostMeta
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
1<?php
2
3declare(strict_types=1);
4
5namespace Beyondwords\Wordpress\Component\Post;
6
7use Beyondwords\Wordpress\Component\Settings\Fields\PlayerStyle\PlayerStyle;
8use Beyondwords\Wordpress\Core\CoreUtils;
9
10/**
11 * BeyondWords Post Meta (Custom Field) Utilities.
12 *
13 * @package    Beyondwords
14 * @subpackage Beyondwords/includes
15 * @author     Stuart McAlpine <stu@beyondwords.io>
16 * @since      3.5.0
17 */
18class PostMetaUtils
19{
20    public const WP_ERROR_FORMAT = 'WP_Error [%s] %s';
21
22    /**
23     * Get "renamed" Post Meta.
24     *
25     * We previously saved custom fields with a prefix of `speechkit_*` and we now
26     * save them with a prefix of `beyondwords_*`.
27     *
28     * This method checks both prefixes, copying `speechkit_*` data to `beyondwords_*`.
29     *
30     * @since 3.7.0
31     *
32     * @param int    $postId Post ID.
33     * @param string $name   Custom field name, without the prefix.
34     *
35     * @return string
36     */
37    public static function getRenamedPostMeta($postId, $name)
38    {
39        if (metadata_exists('post', $postId, 'beyondwords_' . $name)) {
40            return get_post_meta($postId, 'beyondwords_' . $name, true);
41        }
42
43        if (metadata_exists('post', $postId, 'speechkit_' . $name)) {
44            $value = get_post_meta($postId, 'speechkit_' . $name, true);
45
46            // Migrate over to newer `beyondwords_*` format
47            update_post_meta($postId, 'beyondwords_' . $name, $value);
48
49            return $value;
50        }
51
52        return '';
53    }
54
55    /**
56     * Get the BeyondWords metadata for a Post.
57     *
58     * @since 4.1.0 Append 'beyondwords_version' and 'wordpress_version'.
59     */
60    public static function getAllBeyondwordsMetadata($postId)
61    {
62        global $wp_version;
63
64        $keysToCheck = CoreUtils::getPostMetaKeys('all');
65
66        $metadata = has_meta($postId);
67
68        $metadata = array_filter($metadata, function ($item) use ($keysToCheck) {
69            return in_array($item['meta_key'], $keysToCheck);
70        });
71
72        // Prepend the WordPress Post ID to the meta data
73        // phpcs:disable WordPress.DB.SlowDBQuery
74        array_push(
75            $metadata,
76            [
77                'meta_id'    => null,
78                'meta_key'   => 'beyondwords_version',
79                'meta_value' => BEYONDWORDS__PLUGIN_VERSION,
80            ],
81            [
82                'meta_id'    => null,
83                'meta_key'   => 'wordpress_version',
84                'meta_value' => $wp_version,
85            ],
86            [
87                'meta_id'    => null,
88                'meta_key'   => 'wordpress_post_id',
89                'meta_value' => $postId,
90            ],
91        );
92        // phpcs:enable WordPress.DB.SlowDBQuery
93
94        return $metadata;
95    }
96
97    /**
98     * Remove the BeyondWords metadata for a Post.
99     */
100    public static function removeAllBeyondwordsMetadata($postId)
101    {
102        $keysToCheck = [
103            'beyondwords_generate_audio',
104            'beyondwords_project_id',
105            'beyondwords_content_id',
106            'beyondwords_podcast_id',
107            'beyondwords_preview_token',
108            'beyondwords_player_content',
109            'beyondwords_player_style',
110            'beyondwords_language_code',
111            'beyondwords_language_id',
112            'beyondwords_body_voice_id',
113            'beyondwords_title_voice_id',
114            'beyondwords_summary_voice_id',
115            'beyondwords_error_message',
116            'beyondwords_disabled',
117            'beyondwords_delete_content',
118            'publish_post_to_speechkit',
119            'speechkit_generate_audio',
120            'speechkit_project_id',
121            'speechkit_podcast_id',
122            'speechkit_error_message',
123            'speechkit_disabled',
124            'speechkit_access_key',
125            'speechkit_error',
126            'speechkit_info',
127            'speechkit_response',
128            'speechkit_retries',
129            'speechkit_status',
130            '_speechkit_link',
131            '_speechkit_text',
132        ];
133
134        foreach ($keysToCheck as $key) {
135            delete_post_meta($postId, $key, null);
136        }
137
138        return true;
139    }
140
141    /**
142     * Get the Content ID for a WordPress Post.
143     *
144     * Over time there have been various approaches to storing the Content ID.
145     * This function tries each approach in reverse-date order.
146     *
147     * @since 3.0.0
148     * @since 3.5.0 Moved from Core\Utils to Component\Post\PostUtils
149     * @since 4.0.0 Renamed to getContentId() & prioritise beyondwords_content_id
150     * @since 5.0.0 Remove beyondwords_content_id filter.
151     *
152     * @param int $postId Post ID.
153     *
154     * @return int|false Content ID, or false
155     */
156    public static function getContentId($postId)
157    {
158        // Check for "Content ID" custom field (string, uuid)
159        $contentId = get_post_meta($postId, 'beyondwords_content_id', true);
160
161        if (! $contentId) {
162            // Also try "Podcast ID" custom field (number, or string for > 4.x)
163            $contentId = PostMetaUtils::getPodcastId($postId);
164        }
165
166        return $contentId;
167    }
168
169    /**
170     * Get the (legacy) Podcast ID for a WordPress Post.
171     *
172     * Over time there have been various approaches to storing the Podcast ID.
173     * This function tries each approach in reverse-date order.
174     *
175     * @since 3.0.0
176     * @since 3.5.0 Moved from Core\Utils to Component\Post\PostUtils
177     * @since 4.0.0 Allow string values for UUIDs stored >= v4.x
178     *
179     * @param int $postId Post ID.
180     *
181     * @return int|false Podcast ID, or false
182     */
183    public static function getPodcastId($postId)
184    {
185        // Check for "Podcast ID" custom field (number, or string for > 4.x)
186        $podcastId = PostMetaUtils::getRenamedPostMeta($postId, 'podcast_id');
187
188        if ($podcastId) {
189            return $podcastId;
190        }
191
192        // It may also be found by parsing post_meta._speechkit_link
193        $speechkit_link = get_post_meta($postId, '_speechkit_link', true);
194        // Player URL can be either /a/[ID] or /e/[ID] or /m/[ID]
195        preg_match('/\/[aem]\/(\d+)/', (string)$speechkit_link, $matches);
196        if ($matches) {
197            return intval($matches[1]);
198        }
199
200        // It may also be found by parsing post_meta.speechkit_response
201        $speechkit_response = static::getHttpResponseBodyFromPostMeta($postId, 'speechkit_response');
202        preg_match('/"podcast_id":(")?(\d+)(?(1)\1|)/', (string)$speechkit_response, $matches);
203        // $matches[2] is the Podcast ID (response.podcast_id)
204        if ($matches && $matches[2]) {
205            return intval($matches[2]);
206        }
207
208        /**
209         * It may also be found by parsing post_meta.speechkit_info
210         *
211         * NOTE: This has been copied verbatim from the existing iframe player check
212         *       at Speechkit_Public::iframe_player_embed_html(), in case it is
213         *       needed for posts which were created a very long time ago.
214         *       I cannot write unit tests for this to pass, they always fail for me,
215         *       so there are currently no tests for it.
216         **/
217        $article = get_post_meta($postId, 'speechkit_info', true);
218        if (empty($article) || ! isset($article['share_url'])) {
219            // This is exactly the same if/else statement that we have at
220            // Speechkit_Public::iframe_player_embed_html(), but there is
221            // nothing for us to to do here.
222        } else {
223            // This is the part that we need...
224            $url = $article['share_url'];
225
226            // Player URL can be either /a/[ID] or /e/[ID] or /m/[ID]
227            preg_match('/\/[aem]\/(\d+)/', (string)$url, $matches);
228            if ($matches) {
229                return intval($matches[1]);
230            }
231        }
232
233        // todo throw ContentIdNotFoundException???
234
235        return false;
236    }
237
238    /**
239     * Get the BeyondWords preview token for a WordPress Post.
240     *
241     * The preview token allows us to play audio that has a future scheduled
242     * publish date, so we can preview the audio in WordPress admin before it
243     * is published.
244     *
245     * The token is supplied by the BeyondWords REST API whenever audio content
246     * is created/updated, and stored in a WordPress custom field.
247     *
248     * @since 4.5.0
249     *
250     * @param int $postId Post ID.
251     *
252     * @return string Preview token
253     */
254    public static function getPreviewToken($postId)
255    {
256        $previewToken = get_post_meta($postId, 'beyondwords_preview_token', true);
257
258        return $previewToken;
259    }
260
261    /**
262     * Get the 'Generate Audio' value for a Post.
263     *
264     * @since 3.0.0
265     * @since 3.5.0 Moved from Core\Utils to Component\Post\PostUtils
266     *
267     * @param int $postId Post ID.
268     *
269     * @return bool
270     */
271    public static function hasGenerateAudio($postId)
272    {
273        if (PostMetaUtils::getRenamedPostMeta($postId, 'generate_audio') === '1') {
274            return true;
275        }
276
277        if (get_post_meta($postId, 'publish_post_to_speechkit', true) === '1') {
278            return true;
279        }
280
281        $projectId = PostMetaUtils::getProjectId($postId);
282        $contentId = PostMetaUtils::getContentId($postId);
283
284        if ($projectId && $contentId) {
285            return true;
286        }
287
288        return false;
289    }
290
291    /**
292     * Get the Project ID for a WordPress Post.
293     *
294     * It is possible to change the BeyondWords project ID in the plugin settings,
295     * so the current Project ID setting will not necessarily be correct for all
296     * historic Posts. Because of this, we attempt to retrive the correct Project ID
297     * from various custom fields, then fall-back to the plugin setting.
298     *
299     * @since 3.0.0
300     * @since 3.5.0 Moved from Core\Utils to Component\Post\PostUtils
301     * @since 4.0.0 Apply beyondwords_project_id filter
302     * @since 5.0.0 Remove beyondwords_project_id filter.
303     *
304     * @param int $postId Post ID.
305     *
306     * @return int|false Project ID, or false
307     */
308    public static function getProjectId($postId)
309    {
310        // Check custom fields
311        $projectId = intval(PostMetaUtils::getRenamedPostMeta($postId, 'project_id'));
312
313        if (! $projectId) {
314            // It may also be found by parsing post_meta.speechkit_response
315            $speechkit_response = static::getHttpResponseBodyFromPostMeta($postId, 'speechkit_response');
316            preg_match('/"project_id":(")?(\d+)(?(1)\1|)/', (string)$speechkit_response, $matches);
317            // $matches[2] is the Project ID (response.project_id)
318            if ($matches && $matches[2]) {
319                $projectId = intval($matches[2]);
320            }
321        }
322
323        // If we still don't have an ID then use the default from the plugin settings
324        if (! $projectId) {
325            $projectId = intval(get_option('beyondwords_project_id'));
326        }
327
328        if (! $projectId) {
329            // Return boolean false instead of numeric 0
330            $projectId = false;
331        }
332
333        // todo throw ProjectIdNotFoundException?
334
335        return $projectId;
336    }
337
338    /**
339     * Get the Body Voice ID for a WordPress Post.
340     *
341     * We do not filter this, because the Block Editor directly accesses this
342     * custom field, bypassing any filters we add here.
343     *
344     * @since 4.0.0
345     *
346     * @param int $postId Post ID.
347     *
348     * @return int|false Body Voice ID, or false
349     */
350    public static function getBodyVoiceId($postId)
351    {
352        $voiceId = get_post_meta($postId, 'beyondwords_body_voice_id', true);
353
354        return $voiceId;
355    }
356
357    /**
358     * Get the Title Voice ID for a WordPress Post.
359     *
360     * We do not filter this, because the Block Editor directly accesses this
361     * custom field, bypassing any filters we add here.
362     *
363     * @since 4.0.0
364     *
365     * @param int $postId Post ID.
366     *
367     * @return int|false Title Voice ID, or false
368     */
369    public static function getTitleVoiceId($postId)
370    {
371        $voiceId = get_post_meta($postId, 'beyondwords_title_voice_id', true);
372
373        return $voiceId;
374    }
375
376    /**
377     * Get the Summary Voice ID for a WordPress Post.
378     *
379     * We do not filter this, because the Block Editor directly accesses this
380     * custom field, bypassing any filters we add here.
381     *
382     * @since 4.0.0
383     *
384     * @param int $postId Post ID.
385     *
386     * @return int|false Summary Voice ID, or false
387     */
388    public static function getSummaryVoiceId($postId)
389    {
390        $voiceId = get_post_meta($postId, 'beyondwords_summary_voice_id', true);
391
392        return $voiceId;
393    }
394
395    /**
396     * Get the player style for a post.
397     *
398     * Defaults to the plugin setting if the custom field doesn't exist.
399     *
400     * @since 4.1.0
401     *
402     * @param int $postId Post ID.
403     *
404     * @return string Player style.
405     */
406    public static function getPlayerStyle($postId)
407    {
408        $playerStyle = get_post_meta($postId, 'beyondwords_player_style', true);
409
410        // Prefer custom field
411        if ($playerStyle) {
412            return $playerStyle;
413        }
414
415        // Fall back to plugin setting
416        return get_option('beyondwords_player_style', PlayerStyle::STANDARD);
417    }
418
419    /**
420     * Get the "Error Message" value for a WordPress Post.
421     *
422     * Supports data saved with the `beyondwords_*` prefix and the legacy `speechkit_*` prefix.
423     *
424     * @since 3.7.0
425     *
426     * @param int $postId Post ID.
427     *
428     * @return string
429     */
430    public static function getErrorMessage($postId)
431    {
432        return PostMetaUtils::getRenamedPostMeta($postId, 'error_message');
433    }
434
435    /**
436     * Get the "Disabled" value for a WordPress Post.
437     *
438     * Supports data saved with the `beyondwords_*` prefix and the legacy `speechkit_*` prefix.
439     *
440     * @since 3.7.0
441     *
442     * @param int $postId Post ID.
443     *
444     * @return string
445     */
446    public static function getDisabled($postId)
447    {
448        return PostMetaUtils::getRenamedPostMeta($postId, 'disabled');
449    }
450
451    /**
452     * Get HTTP response body from post meta.
453     *
454     * The data may have been saved as a WordPress HTTP response array. If it was,
455     * then return the 'body' key of the HTTP response instead of the raw post meta.
456     *
457     * The data may also have been saved as a WordPress WP_Error instance. If it was,
458     * then return a string containing the WP_Error code and message.
459     *
460     * @since 3.0.3
461     * @since 3.5.0 Moved from Core\Utils to Component\Post\PostUtils
462     * @since 3.6.1 Handle responses saved as object of class WP_Error
463     *
464     * @param int    $postId   Post ID.
465     * @param string $metaName Post Meta name.
466     *
467     * @return string
468     */
469    public static function getHttpResponseBodyFromPostMeta($postId, $metaName)
470    {
471        $postMeta = get_post_meta($postId, $metaName, true);
472
473        if (is_array($postMeta)) {
474            return (string)wp_remote_retrieve_body($postMeta);
475        }
476
477        if (is_wp_error($postMeta)) {
478            return sprintf(PostMetaUtils::WP_ERROR_FORMAT, $postMeta->get_error_code(), $postMeta->get_error_message());
479        }
480
481        return (string)$postMeta;
482    }
483}