Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.10% covered (success)
95.10%
97 / 102
85.71% covered (success)
85.71%
12 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Meta
95.10% covered (success)
95.10%
97 / 102
85.71% covered (success)
85.71%
12 / 14
44
0.00% covered (danger)
0.00%
0 / 1
 get_renamed_post_meta
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 get_all_beyondwords_metadata
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
1
 remove_all_beyondwords_metadata
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 has_content
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 get_content_id
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 sanitize_content_id
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 get_podcast_id
76.47% covered (warning)
76.47%
13 / 17
0.00% covered (danger)
0.00%
0 / 1
8.83
 get_preview_token
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 has_generate_audio
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 get_project_id
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
6.02
 get_body_voice_id
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 get_error_message
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_disabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_http_response_body_from_post_meta
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare( strict_types = 1 );
4
5namespace BeyondWords\Post;
6
7/**
8 * BeyondWords Post Meta (Custom Field) Utilities.
9 *
10 * @package    Beyondwords
11 * @subpackage Beyondwords/includes
12 * @author     Stuart McAlpine <stu@beyondwords.io>
13 * @since      3.5.0
14 * @since      7.0.0 Refactored to BeyondWords namespace with snake_case methods.
15 */
16defined( 'ABSPATH' ) || exit;
17
18class Meta {
19
20    public const WP_ERROR_FORMAT = 'WP_Error [%s] %s';
21
22    /**
23     * Get "renamed" Post Meta.
24     *
25     * Checks `beyondwords_*` then the legacy `speechkit_*` prefix, migrating
26     * legacy values to the new prefix on read.
27     *
28     * @since 3.7.0
29     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
30     *
31     * @param int    $post_id Post ID.
32     * @param string $name   Custom field name, without the prefix.
33     *
34     * @return string
35     */
36    public static function get_renamed_post_meta( int $post_id, string $name ): mixed {
37        if ( metadata_exists( 'post', $post_id, 'beyondwords_' . $name ) ) {
38            return get_post_meta( $post_id, 'beyondwords_' . $name, true );
39        }
40
41        if ( metadata_exists( 'post', $post_id, 'speechkit_' . $name ) ) {
42            $value = get_post_meta( $post_id, 'speechkit_' . $name, true );
43
44            update_post_meta( $post_id, 'beyondwords_' . $name, $value );
45
46            return $value;
47        }
48
49        return '';
50    }
51
52    /**
53     * Get the BeyondWords metadata for a Post.
54     *
55     * @since 4.1.0 Append 'beyondwords_version' and 'wordpress_version'.
56     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
57     */
58    public static function get_all_beyondwords_metadata( int $post_id ): array {
59        global $wp_version;
60
61        $keys_to_check = \BeyondWords\Core\Utils::get_post_meta_keys( 'all' );
62
63        $metadata = has_meta( $post_id );
64
65        $metadata = array_filter( $metadata, fn( $item ) => in_array( $item['meta_key'], $keys_to_check ) );
66
67        // phpcs:disable WordPress.DB.SlowDBQuery
68        array_push(
69            $metadata,
70            [
71                'meta_id'    => null,
72                'meta_key'   => 'beyondwords_version',
73                'meta_value' => BEYONDWORDS__PLUGIN_VERSION,
74            ],
75            [
76                'meta_id'    => null,
77                'meta_key'   => 'wordpress_version',
78                'meta_value' => $wp_version,
79            ],
80            [
81                'meta_id'    => null,
82                'meta_key'   => 'wordpress_post_id',
83                'meta_value' => $post_id,
84            ],
85        );
86        // phpcs:enable WordPress.DB.SlowDBQuery
87
88        return $metadata;
89    }
90
91    /**
92     * Remove the BeyondWords metadata for a Post.
93     *
94     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
95     *
96     * @param int $post_id Post ID.
97     *
98     * @since 4.x   Introduced.
99     * @since 6.0.1 Use \BeyondWords\Core\Utils::get_post_meta_keys() to get all keys.
100     */
101    public static function remove_all_beyondwords_metadata( int $post_id ): void {
102        $keys = \BeyondWords\Core\Utils::get_post_meta_keys( 'all' );
103
104        foreach ( $keys as $key ) {
105            delete_post_meta( $post_id, $key, null );
106        }
107    }
108
109    /**
110     * Check if a Post should have BeyondWords content (a Content entity in BeyondWords).
111     *
112     * @since 6.0.0 Introduced.
113     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
114     *
115     * @param int $post_id Post ID.
116     *
117     * @return bool True if the post should have BeyondWords content, false otherwise.
118     */
119    public static function has_content( int $post_id ): bool {
120        $content_id         = self::get_content_id( $post_id );
121        $integration_method = get_post_meta( $post_id, 'beyondwords_integration_method', true );
122
123        // Unset means REST API, for legacy compatibility.
124        if ( empty( $integration_method ) ) {
125            $integration_method = \BeyondWords\Settings\Fields::INTEGRATION_REST_API;
126        }
127
128        if ( \BeyondWords\Settings\Fields::INTEGRATION_REST_API === $integration_method && ! empty( $content_id ) ) {
129            return true;
130        }
131
132        // Strict: don't fall back to the plugin-setting project ID.
133        $project_id = self::get_project_id( $post_id, true );
134
135        if ( \BeyondWords\Settings\Fields::INTEGRATION_CLIENT_SIDE === $integration_method && ! empty( $project_id ) ) {
136            return true;
137        }
138
139        return false;
140    }
141
142    /**
143     * Get the Content ID for a WordPress Post.
144     *
145     * Over time there have been various approaches to storing the Content ID.
146     * This function tries each approach in reverse-date order.
147     *
148     * @since 3.0.0
149     * @since 3.5.0 Moved from Core\Utils to Component\Post\PostUtils
150     * @since 4.0.0 Renamed to getContentId() & prioritise beyondwords_content_id
151     * @since 5.0.0 Remove beyondwords_content_id filter.
152     * @since 6.0.0 Add fallback parameter to allow falling back to Post ID.
153     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
154     *
155     * @param int  $post_id Post ID.
156     * @param bool $fallback If true, will fall back to the Post ID if no Content ID is found.
157     *
158     * @return string|false Content ID, or false
159     */
160    public static function get_content_id( int $post_id, bool $fallback = false ): string|int|false {
161        $content_id = get_post_meta( $post_id, 'beyondwords_content_id', true );
162        if ( ! empty( $content_id ) ) {
163            return $content_id;
164        }
165
166        $podcast_id = self::get_podcast_id( $post_id );
167        if ( ! empty( $podcast_id ) ) {
168            return $podcast_id;
169        }
170
171        if ( $fallback ) {
172            return (string) $post_id;
173        }
174
175        return false;
176    }
177
178    /**
179     * Sanitize a BeyondWords Content ID (numeric ID or UUID).
180     *
181     * Anything beyond alphanumerics and hyphens is rejected: the ID is later
182     * interpolated into API URL paths, where `/`, `?`, `#` etc. would allow
183     * path/query injection against the authenticated API.
184     *
185     * @since 7.0.0
186     *
187     * @param mixed $content_id Raw submitted Content ID.
188     *
189     * @return string Sanitized Content ID, or '' when the value is invalid.
190     */
191    public static function sanitize_content_id( $content_id ): string {
192        $content_id = sanitize_text_field( (string) $content_id );
193
194        return preg_match( '/^[a-zA-Z0-9-]*$/', $content_id ) ? $content_id : '';
195    }
196
197    /**
198     * Get the (legacy) Podcast ID for a WordPress Post.
199     *
200     * Over time there have been various approaches to storing the Podcast ID.
201     * This function tries each approach in reverse-date order.
202     *
203     * @since 3.0.0
204     * @since 3.5.0 Moved from Core\Utils to Component\Post\PostUtils
205     * @since 4.0.0 Allow string values for UUIDs stored >= v4.x
206     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
207     *
208     * @param int $post_id Post ID.
209     *
210     * @return int|false Podcast ID, or false
211     */
212    public static function get_podcast_id( int $post_id ): string|int|false {
213        $podcast_id = self::get_renamed_post_meta( $post_id, 'podcast_id' );
214
215        if ( $podcast_id ) {
216            return $podcast_id;
217        }
218
219        // Legacy player URLs are /a/[ID], /e/[ID] or /m/[ID].
220        $speechkit_link = get_post_meta( $post_id, '_speechkit_link', true );
221        preg_match( '/\/[aem]\/(\d+)/', (string) $speechkit_link, $matches );
222        if ( $matches ) {
223            return intval( $matches[1] );
224        }
225
226        $speechkit_response = self::get_http_response_body_from_post_meta( $post_id, 'speechkit_response' );
227        preg_match( '/"podcast_id":(")?(\d+)(?(1)\1|)/', (string) $speechkit_response, $matches );
228        if ( $matches && $matches[2] ) {
229            return intval( $matches[2] );
230        }
231
232        // Mirrors the if/else at legacy Speechkit_Public::iframe_player_embed_html();
233        // only the share_url branch produces a usable Podcast ID for us.
234        $article = get_post_meta( $post_id, 'speechkit_info', true );
235        if ( ! empty( $article ) && isset( $article['share_url'] ) ) {
236            preg_match( '/\/[aem]\/(\d+)/', (string) $article['share_url'], $matches );
237            if ( $matches ) {
238                return intval( $matches[1] );
239            }
240        }
241
242        return false;
243    }
244
245    /**
246     * Get the BeyondWords preview token for a WordPress Post.
247     *
248     * The token allows previewing audio in admin before its scheduled publish date.
249     *
250     * @since 4.5.0
251     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
252     *
253     * @param int $post_id Post ID.
254     *
255     * @return string Preview token
256     */
257    public static function get_preview_token( int $post_id ): string|false {
258        $preview_token = get_post_meta( $post_id, 'beyondwords_preview_token', true );
259
260        return $preview_token ?: false;
261    }
262
263    /**
264     * Get the 'Generate Audio' value for a Post.
265     *
266     * @since 3.0.0
267     * @since 3.5.0 Moved from Core\Utils to Component\Post\PostUtils
268     * @since 6.0.0 Add Magic Embed support.
269     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
270     *
271     * @param int $post_id Post ID.
272     */
273    public static function has_generate_audio( int $post_id ): bool {
274        $generate_audio = self::get_renamed_post_meta( $post_id, 'generate_audio' );
275
276        if ( $generate_audio === '1' ) {
277            return true;
278        }
279
280        if ( $generate_audio === '0' ) {
281            return false;
282        }
283
284        return \BeyondWords\Editor\Components\GenerateAudio::should_preselect_generate_audio( $post_id );
285    }
286
287    /**
288     * Get the Project ID for a WordPress Post.
289     *
290     * The plugin-setting project ID may have changed since a post was created,
291     * so historic custom fields are checked before falling back to the setting.
292     *
293     * @since 3.0.0
294     * @since 3.5.0 Moved from Core\Utils to Component\Post\PostUtils
295     * @since 4.0.0 Apply beyondwords_project_id filter
296     * @since 5.0.0 Remove beyondwords_project_id filter.
297     * @since 6.0.0 Support Magic Embed and add strict mode.
298     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
299     *
300     * @param int  $post_id Post ID.
301     * @param bool $strict Strict mode, which only checks the custom field. Defaults to false.
302     *
303     * @return int|false Project ID, or false
304     */
305    public static function get_project_id( int $post_id, bool $strict = false ): int|string|false {
306        if ( $strict ) {
307            return self::get_renamed_post_meta( $post_id, 'project_id' );
308        }
309
310        $post_meta = intval( self::get_renamed_post_meta( $post_id, 'project_id' ) );
311
312        if ( ! empty( $post_meta ) ) {
313            return $post_meta;
314        }
315
316        $speechkit_response = self::get_http_response_body_from_post_meta( $post_id, 'speechkit_response' );
317
318        preg_match( '/"project_id":(")?(\d+)(?(1)\1|)/', (string) $speechkit_response, $matches );
319
320        if ( $matches && $matches[2] ) {
321            return intval( $matches[2] );
322        }
323
324        $setting = get_option( 'beyondwords_project_id' );
325
326        if ( $setting ) {
327            return intval( $setting );
328        }
329
330        return false;
331    }
332
333    /**
334     * Get the Body Voice ID for a WordPress Post.
335     *
336     * We do not filter this, because the Block Editor directly accesses this
337     * custom field, bypassing any filters we add here.
338     *
339     * @since 4.0.0
340     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
341     *
342     * @param int $post_id Post ID.
343     *
344     * @return int|false Body Voice ID, or false
345     */
346    public static function get_body_voice_id( int $post_id ): int|string|false {
347        $voice_id = get_post_meta( $post_id, 'beyondwords_body_voice_id', true );
348
349        return $voice_id ?: false;
350    }
351
352    /**
353     * Get the "Error Message" value for a WordPress Post.
354     *
355     * @since 3.7.0
356     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
357     *
358     * @param int $post_id Post ID.
359     *
360     * @return string
361     */
362    public static function get_error_message( int $post_id ): string|false {
363        return self::get_renamed_post_meta( $post_id, 'error_message' );
364    }
365
366    /**
367     * Get the "Disabled" value for a WordPress Post.
368     *
369     * @since 3.7.0
370     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
371     *
372     * @param int $post_id Post ID.
373     */
374    public static function get_disabled( int $post_id ): bool {
375        return (bool) self::get_renamed_post_meta( $post_id, 'disabled' );
376    }
377
378    /**
379     * Get HTTP response body from post meta.
380     *
381     * Legacy rows may hold a whole WordPress HTTP response array or a WP_Error;
382     * both are normalised to a string.
383     *
384     * @since 3.0.3
385     * @since 3.5.0 Moved from Core\Utils to Component\Post\PostUtils
386     * @since 3.6.1 Handle responses saved as object of class WP_Error
387     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
388     *
389     * @param int    $post_id   Post ID.
390     * @param string $meta_name Post Meta name.
391     *
392     * @return string
393     */
394    public static function get_http_response_body_from_post_meta( int $post_id, string $meta_name ): array|string|false {
395        $post_meta = get_post_meta( $post_id, $meta_name, true );
396
397        if ( is_array( $post_meta ) ) {
398            return (string) wp_remote_retrieve_body( $post_meta );
399        }
400
401        if ( is_wp_error( $post_meta ) ) {
402            return sprintf( self::WP_ERROR_FORMAT, $post_meta->get_error_code(), $post_meta->get_error_message() );
403        }
404
405        return (string) $post_meta;
406    }
407}