Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.21% covered (success)
89.21%
124 / 139
56.25% covered (danger)
56.25%
9 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
PostMetaUtils
89.21% covered (success)
89.21%
124 / 139
56.25% covered (danger)
56.25%
9 / 16
50.90
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%
23 / 23
100.00% covered (success)
100.00%
1 / 1
1
 removeAllBeyondwordsMetadata
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
2
 hasContent
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
6.04
 getContentId
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 getPodcastId
72.22% covered (warning)
72.22%
13 / 18
0.00% covered (danger)
0.00%
0 / 1
9.37
 getPreviewToken
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 hasGenerateAudio
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getProjectId
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
6.02
 getBodyVoiceId
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 getTitleVoiceId
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 getSummaryVoiceId
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 getPlayerStyle
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 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\Post\GenerateAudio\GenerateAudio;
8use Beyondwords\Wordpress\Component\Settings\Fields\IntegrationMethod\IntegrationMethod;
9use Beyondwords\Wordpress\Component\Settings\Fields\PlayerStyle\PlayerStyle;
10use Beyondwords\Wordpress\Core\CoreUtils;
11
12/**
13 * BeyondWords Post Meta (Custom Field) Utilities.
14 *
15 * @package    Beyondwords
16 * @subpackage Beyondwords/includes
17 * @author     Stuart McAlpine <stu@beyondwords.io>
18 * @since      3.5.0
19 */
20class PostMetaUtils
21{
22    public const WP_ERROR_FORMAT = 'WP_Error [%s] %s';
23
24    /**
25     * Get "renamed" Post Meta.
26     *
27     * We previously saved custom fields with a prefix of `speechkit_*` and we now
28     * save them with a prefix of `beyondwords_*`.
29     *
30     * This method checks both prefixes, copying `speechkit_*` data to `beyondwords_*`.
31     *
32     * @since 3.7.0
33     *
34     * @param int    $postId Post ID.
35     * @param string $name   Custom field name, without the prefix.
36     *
37     * @return string
38     */
39    public static function getRenamedPostMeta(int $postId, string $name): mixed
40    {
41        if (metadata_exists('post', $postId, 'beyondwords_' . $name)) {
42            return get_post_meta($postId, 'beyondwords_' . $name, true);
43        }
44
45        if (metadata_exists('post', $postId, 'speechkit_' . $name)) {
46            $value = get_post_meta($postId, 'speechkit_' . $name, true);
47
48            // Migrate over to newer `beyondwords_*` format
49            update_post_meta($postId, 'beyondwords_' . $name, $value);
50
51            return $value;
52        }
53
54        return '';
55    }
56
57    /**
58     * Get the BeyondWords metadata for a Post.
59     *
60     * @since 4.1.0 Append 'beyondwords_version' and 'wordpress_version'.
61     */
62    public static function getAllBeyondwordsMetadata(int $postId): array
63    {
64        global $wp_version;
65
66        $keysToCheck = CoreUtils::getPostMetaKeys('all');
67
68        $metadata = has_meta($postId);
69
70        $metadata = array_filter($metadata, fn($item) => in_array($item['meta_key'], $keysToCheck));
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(int $postId): void
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
139    /**
140     * Check if a Post should have BeyondWords content (a Content entity in BeyondWords).
141     *
142     * @since 6.0.0 Introduced.
143     *
144     * @param int $postId Post ID.
145     *
146     * @return bool True if the post should have BeyondWords content, false otherwise.
147     */
148    public static function hasContent(int $postId): bool
149    {
150        $contentId         = PostMetaUtils::getContentId($postId);
151        $integrationMethod = get_post_meta($postId, 'beyondwords_integration_method', true);
152
153        // If the integration method is not set, we assume REST API for legacy compatibility.
154        if (empty($integrationMethod)) {
155            $integrationMethod = IntegrationMethod::REST_API;
156        }
157
158        if (IntegrationMethod::REST_API === $integrationMethod && ! empty($contentId)) {
159            return true;
160        }
161
162        // Get the project ID for the post (do not use the plugin setting).
163        $projectId = PostMetaUtils::getProjectId($postId, true);
164
165        if (IntegrationMethod::CLIENT_SIDE === $integrationMethod && ! empty($projectId)) {
166            return true;
167        }
168
169        return false;
170    }
171
172    /**
173     * Get the Content ID for a WordPress Post.
174     *
175     * Over time there have been various approaches to storing the Content ID.
176     * This function tries each approach in reverse-date order.
177     *
178     * @since 3.0.0
179     * @since 3.5.0 Moved from Core\Utils to Component\Post\PostUtils
180     * @since 4.0.0 Renamed to getContentId() & prioritise beyondwords_content_id
181     * @since 5.0.0 Remove beyondwords_content_id filter.
182     * @since 6.0.0 Add fallback parameter to allow falling back to Post ID.
183     *
184     * @param int  $postId Post ID.
185     * @param bool $fallback If true, will fall back to the Post ID if no Content ID is found.
186     *
187     * @return string|false Content ID, or false
188     */
189    public static function getContentId(int $postId, bool $fallback = false): string|int|false
190    {
191        $contentId = get_post_meta($postId, 'beyondwords_content_id', true);
192        if (! empty($contentId)) {
193            return $contentId;
194        }
195
196        $podcastId = PostMetaUtils::getPodcastId($postId);
197        if (! empty($podcastId)) {
198            return $podcastId;
199        }
200
201        if ($fallback) {
202            return (string) $postId;
203        }
204
205        return false;
206    }
207
208    /**
209     * Get the (legacy) Podcast ID for a WordPress Post.
210     *
211     * Over time there have been various approaches to storing the Podcast ID.
212     * This function tries each approach in reverse-date order.
213     *
214     * @since 3.0.0
215     * @since 3.5.0 Moved from Core\Utils to Component\Post\PostUtils
216     * @since 4.0.0 Allow string values for UUIDs stored >= v4.x
217     *
218     * @param int $postId Post ID.
219     *
220     * @return int|false Podcast ID, or false
221     */
222    public static function getPodcastId(int $postId): string|int|false
223    {
224        // Check for "Podcast ID" custom field (number, or string for > 4.x)
225        $podcastId = PostMetaUtils::getRenamedPostMeta($postId, 'podcast_id');
226
227        if ($podcastId) {
228            return $podcastId;
229        }
230
231        // It may also be found by parsing post_meta._speechkit_link
232        $speechkit_link = get_post_meta($postId, '_speechkit_link', true);
233        // Player URL can be either /a/[ID] or /e/[ID] or /m/[ID]
234        preg_match('/\/[aem]\/(\d+)/', (string)$speechkit_link, $matches);
235        if ($matches) {
236            return intval($matches[1]);
237        }
238
239        // It may also be found by parsing post_meta.speechkit_response
240        $speechkit_response = self::getHttpResponseBodyFromPostMeta($postId, 'speechkit_response');
241        preg_match('/"podcast_id":(")?(\d+)(?(1)\1|)/', (string)$speechkit_response, $matches);
242        // $matches[2] is the Podcast ID (response.podcast_id)
243        if ($matches && $matches[2]) {
244            return intval($matches[2]);
245        }
246
247        /**
248         * It may also be found by parsing post_meta.speechkit_info
249         *
250         * NOTE: This has been copied verbatim from the existing iframe player check
251         *       at Speechkit_Public::iframe_player_embed_html(), in case it is
252         *       needed for posts which were created a very long time ago.
253         *       I cannot write unit tests for this to pass, they always fail for me,
254         *       so there are currently no tests for it.
255         **/
256        $article = get_post_meta($postId, 'speechkit_info', true);
257        if (empty($article) || ! isset($article['share_url'])) {
258            // This is exactly the same if/else statement that we have at
259            // Speechkit_Public::iframe_player_embed_html(), but there is
260            // nothing for us to to do here.
261        } else {
262            // This is the part that we need...
263            $url = $article['share_url'];
264
265            // Player URL can be either /a/[ID] or /e/[ID] or /m/[ID]
266            preg_match('/\/[aem]\/(\d+)/', (string)$url, $matches);
267            if ($matches) {
268                return intval($matches[1]);
269            }
270        }
271
272        // todo throw ContentIdNotFoundException???
273
274        return false;
275    }
276
277    /**
278     * Get the BeyondWords preview token for a WordPress Post.
279     *
280     * The preview token allows us to play audio that has a future scheduled
281     * publish date, so we can preview the audio in WordPress admin before it
282     * is published.
283     *
284     * The token is supplied by the BeyondWords REST API whenever audio content
285     * is created/updated, and stored in a WordPress custom field.
286     *
287     * @since 4.5.0
288     *
289     * @param int $postId Post ID.
290     *
291     * @return string Preview token
292     */
293    public static function getPreviewToken(int $postId): string|false
294    {
295        $previewToken = get_post_meta($postId, 'beyondwords_preview_token', true);
296
297        return $previewToken ?: false;
298    }
299
300    /**
301     * Get the 'Generate Audio' value for a Post.
302     *
303     * @since 3.0.0
304     * @since 3.5.0 Moved from Core\Utils to Component\Post\PostUtils
305     * @since 6.0.0 Add Magic Embed support.
306     *
307     * @param int $postId Post ID.
308     */
309    public static function hasGenerateAudio(int $postId): bool
310    {
311        $generateAudio = PostMetaUtils::getRenamedPostMeta($postId, 'generate_audio');
312
313        // Checkbox was checked.
314        if ($generateAudio === '1') {
315            return true;
316        }
317
318        // Checkbox was unchecked.
319        if ($generateAudio === '0') {
320            return false;
321        }
322
323        return GenerateAudio::shouldPreselectGenerateAudio($postId);
324    }
325
326    /**
327     * Get the Project ID for a WordPress Post.
328     *
329     * It is possible to change the BeyondWords project ID in the plugin settings,
330     * so the current Project ID setting will not necessarily be correct for all
331     * historic Posts. Because of this, we attempt to retrive the correct Project ID
332     * from various custom fields, then fall-back to the plugin setting.
333     *
334     * @since 3.0.0
335     * @since 3.5.0 Moved from Core\Utils to Component\Post\PostUtils
336     * @since 4.0.0 Apply beyondwords_project_id filter
337     * @since 5.0.0 Remove beyondwords_project_id filter.
338     * @since 6.0.0 Support Magic Embed and add strict mode.
339     *
340     * @param int  $postId Post ID.
341     * @param bool $strict Strict mode, which only checks the custom field. Defaults to false.
342     *
343     * @return int|false Project ID, or false
344     */
345    public static function getProjectId(int $postId, bool $strict = false): int|string|false
346    {
347        // If strict is true, we only check the custom field and do not fall back to the plugin setting.
348        if ($strict) {
349            return PostMetaUtils::getRenamedPostMeta($postId, 'project_id');
350        }
351
352        // Check the post custom field.
353        $postMeta = intval(PostMetaUtils::getRenamedPostMeta($postId, 'project_id'));
354
355        if (! empty($postMeta)) {
356            return $postMeta;
357        }
358
359        // Parse post_meta.speechkit_response, if available.
360        $speechkit_response = self::getHttpResponseBodyFromPostMeta($postId, 'speechkit_response');
361
362        preg_match('/"project_id":(")?(\d+)(?(1)\1|)/', (string)$speechkit_response, $matches);
363
364        // $matches[2] is the Project ID (response.project_id)
365        if ($matches && $matches[2]) {
366            return intval($matches[2]);
367        }
368
369        // Check the plugin setting.
370        $setting = get_option('beyondwords_project_id');
371
372        if ($setting) {
373            return intval($setting);
374        }
375
376        // todo throw ProjectIdNotFoundException?
377
378        return false;
379    }
380
381    /**
382     * Get the Body Voice ID for a WordPress Post.
383     *
384     * We do not filter this, because the Block Editor directly accesses this
385     * custom field, bypassing any filters we add here.
386     *
387     * @since 4.0.0
388     *
389     * @param int $postId Post ID.
390     *
391     * @return int|false Body Voice ID, or false
392     */
393    public static function getBodyVoiceId(int $postId): int|string|false
394    {
395        $voiceId = get_post_meta($postId, 'beyondwords_body_voice_id', true);
396
397        return $voiceId ?: false;
398    }
399
400    /**
401     * Get the Title Voice ID for a WordPress Post.
402     *
403     * We do not filter this, because the Block Editor directly accesses this
404     * custom field, bypassing any filters we add here.
405     *
406     * @since 4.0.0
407     *
408     * @param int $postId Post ID.
409     *
410     * @return int|false Title Voice ID, or false
411     */
412    public static function getTitleVoiceId(int $postId): int|string|false
413    {
414        $voiceId = get_post_meta($postId, 'beyondwords_title_voice_id', true);
415
416        return $voiceId ?: false;
417    }
418
419    /**
420     * Get the Summary Voice ID for a WordPress Post.
421     *
422     * We do not filter this, because the Block Editor directly accesses this
423     * custom field, bypassing any filters we add here.
424     *
425     * @since 4.0.0
426     *
427     * @param int $postId Post ID.
428     *
429     * @return int|false Summary Voice ID, or false
430     */
431    public static function getSummaryVoiceId(int $postId): int|string|false
432    {
433        $voiceId = get_post_meta($postId, 'beyondwords_summary_voice_id', true);
434
435        return $voiceId ?: false;
436    }
437
438    /**
439     * Get the player style for a post.
440     *
441     * Defaults to the plugin setting if the custom field doesn't exist.
442     *
443     * @since 4.1.0
444     *
445     * @param int $postId Post ID.
446     *
447     * @return string Player style.
448     */
449    public static function getPlayerStyle(int $postId): string
450    {
451        $playerStyle = get_post_meta($postId, 'beyondwords_player_style', true);
452
453        // Prefer custom field
454        if ($playerStyle) {
455            return $playerStyle;
456        }
457
458        // Fall back to plugin setting
459        return get_option('beyondwords_player_style', PlayerStyle::STANDARD);
460    }
461
462    /**
463     * Get the "Error Message" value for a WordPress Post.
464     *
465     * Supports data saved with the `beyondwords_*` prefix and the legacy `speechkit_*` prefix.
466     *
467     * @since 3.7.0
468     *
469     * @param int $postId Post ID.
470     *
471     * @return string
472     */
473    public static function getErrorMessage(int $postId): string|false
474    {
475        return PostMetaUtils::getRenamedPostMeta($postId, 'error_message');
476    }
477
478    /**
479     * Get the "Disabled" value for a WordPress Post.
480     *
481     * Supports data saved with the `beyondwords_*` prefix and the legacy `speechkit_*` prefix.
482     *
483     * @since 3.7.0
484     *
485     * @param int $postId Post ID.
486     */
487    public static function getDisabled(int $postId): bool
488    {
489        return (bool) PostMetaUtils::getRenamedPostMeta($postId, 'disabled');
490    }
491
492    /**
493     * Get HTTP response body from post meta.
494     *
495     * The data may have been saved as a WordPress HTTP response array. If it was,
496     * then return the 'body' key of the HTTP response instead of the raw post meta.
497     *
498     * The data may also have been saved as a WordPress WP_Error instance. If it was,
499     * then return a string containing the WP_Error code and message.
500     *
501     * @since 3.0.3
502     * @since 3.5.0 Moved from Core\Utils to Component\Post\PostUtils
503     * @since 3.6.1 Handle responses saved as object of class WP_Error
504     *
505     * @param int    $postId   Post ID.
506     * @param string $metaName Post Meta name.
507     *
508     * @return string
509     */
510    public static function getHttpResponseBodyFromPostMeta(int $postId, string $metaName): array|string|false
511    {
512        $postMeta = get_post_meta($postId, $metaName, true);
513
514        if (is_array($postMeta)) {
515            return (string)wp_remote_retrieve_body($postMeta);
516        }
517
518        if (is_wp_error($postMeta)) {
519            return sprintf(PostMetaUtils::WP_ERROR_FORMAT, $postMeta::get_error_code(), $postMeta::get_error_message());
520        }
521
522        return (string)$postMeta;
523    }
524}