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