Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
83.95% covered (warning)
83.95%
136 / 162
50.00% covered (danger)
50.00%
5 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Metabox
83.85% covered (warning)
83.85%
135 / 161
50.00% covered (danger)
50.00%
5 / 10
25.23
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 add_meta_box_callback
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
2.00
 render_meta_box_content
96.43% covered (success)
96.43%
27 / 28
0.00% covered (danger)
0.00%
0 / 1
4
 heading
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 pending_review_notice
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
6
 player_embed
95.56% covered (success)
95.56%
43 / 45
0.00% covered (danger)
0.00%
0 / 1
5
 awaiting_generation_embed
90.48% covered (success)
90.48%
19 / 21
0.00% covered (danger)
0.00%
0 / 1
4.01
 errors
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 help
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 regenerate_instructions
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare( strict_types = 1 );
4
5/**
6 * BeyondWords Post Metabox.
7 *
8 * @package BeyondWords\Editor\Classic
9 * @author  Stuart McAlpine <stu@beyondwords.io>
10 * @since   3.0.0
11 * @since   7.0.0 Refactored to BeyondWords namespace with snake_case methods.
12 */
13
14namespace BeyondWords\Editor\Classic;
15
16/**
17 * PostMetabox
18 *
19 * @since 3.0.0
20 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
21 */
22defined( 'ABSPATH' ) || exit;
23
24class Metabox {
25
26    /**
27     * Init.
28     *
29     * @since 4.0.0
30     * @since 6.0.0 Make static.
31     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
32     */
33    public static function init() {
34        add_action( 'add_meta_boxes', [ self::class, 'add_meta_box_callback'] );
35    }
36
37    /**
38     * Adds the meta box container.
39     *
40     * @since 6.0.0 Make static.
41     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
42     *
43     * @param string $post_type
44     */
45    public static function add_meta_box_callback( $post_type ) {
46        $post_types = \BeyondWords\Settings\Utils::get_compatible_post_types();
47
48        if ( ! in_array( $post_type, $post_types ) ) {
49            return;
50        }
51
52        add_meta_box(
53            'beyondwords',
54            __( 'BeyondWords', 'speechkit' ),
55            [ self::class, 'render_meta_box_content'],
56            $post_type,
57            'side',
58            'default',
59            [
60                '__back_compat_meta_box' => true,
61            ]
62        );
63    }
64
65    /**
66     * Render Meta Box content.
67     *
68     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
69     *
70     * @param int|\WP_Post $post The WordPress post ID, or post object.
71     *
72     * @since 3.0.0
73     * @since 3.7.0 Show "Pending review" notice for posts with status of "pending"
74     * @since 4.0.0 Content ID is no longer an int
75     * @since 4.1.0 Add "Player style" and update component display conditions
76     * @since 6.0.0 Make static and add Magic Embed support.
77     */
78    public static function render_meta_box_content( $post ) {
79        $post = get_post( $post );
80
81        if ( ! ( $post instanceof \WP_Post ) ) {
82            return;
83        }
84
85        $has_content = \BeyondWords\Post\Meta::has_content( $post->ID );
86
87        // Single nonce guarding the Content/Format/Player <select> fields.
88        \BeyondWords\Editor\Components\SettingsFields::nonce();
89
90        self::heading( __( 'Player', 'speechkit' ) );
91
92        self::errors( $post );
93
94        if ( $has_content ) {
95            if ( get_post_status( $post ) === 'pending' ) {
96                self::pending_review_notice( $post );
97            } else {
98                self::player_embed( $post );
99            }
100        } else {
101            self::awaiting_generation_embed( $post );
102        }
103
104        // The Embed dropdown ("None" = no player) replaces the old Display player checkbox.
105        \BeyondWords\Editor\Components\SettingsFields::render_player_section( $post );
106        ( new \BeyondWords\Editor\Components\GenerateAudio() )::element( $post );
107
108        echo '<hr class="beyondwords-metabox__divider" />';
109        self::heading( __( 'Content', 'speechkit' ) );
110        \BeyondWords\Editor\Components\SettingsFields::render_content_section( $post );
111
112        echo '<hr class="beyondwords-metabox__divider" />';
113        self::heading( __( 'Format', 'speechkit' ) );
114        \BeyondWords\Editor\Components\SettingsFields::render_format_section( $post );
115
116        echo '<hr class="beyondwords-metabox__divider" />';
117        self::heading( __( 'Voice', 'speechkit' ) );
118        ( new \BeyondWords\Editor\Components\SelectVoice() )::element( $post );
119
120        echo '<hr class="beyondwords-metabox__divider" />';
121        self::heading( __( 'Data', 'speechkit' ) );
122        \BeyondWords\Editor\Components\ContentId::element( $post );
123
124        echo '<hr class="beyondwords-metabox__divider" />';
125        self::help();
126    }
127
128    /**
129     * Print a settings-section heading.
130     *
131     * @since 7.0.0
132     *
133     * @param string $title The section heading.
134     */
135    public static function heading( $title ) {
136        printf(
137            '<h4 class="beyondwords-metabox__heading">%s</h4>',
138            esc_html( $title )
139        );
140    }
141
142    /**
143     * The "Pending review" message for posts with "pending" status.
144     *
145     * Shown instead of the player, which cannot render audio created with
146     * { published: false }.
147     *
148     * @since 3.7.0
149     * @since 6.0.0 Make static.
150     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
151     *
152     * @var int|\WP_Post $post The WordPress post ID, or post object.
153     */
154    public static function pending_review_notice( $post ) {
155        $post = get_post( $post );
156
157        if ( ! ( $post instanceof \WP_Post ) ) {
158            return;
159        }
160
161        $project_url = sprintf(
162            '%s/dashboard/project/%d/content',
163            \BeyondWords\Core\Urls::get_dashboard_url(),
164            \BeyondWords\Post\Meta::get_project_id( $post->ID )
165        );
166
167        ?>
168        <div id="beyondwords-pending-review-message">
169            <?php
170            printf(
171                /* translators: %s is replaced with the link to the BeyondWords dashboard */
172                esc_html__( 'Listen to content saved as “Pending” in the %s.', 'speechkit' ),
173                sprintf(
174                    '<a href="%s" target="_blank" rel="nofollow">%s</a>',
175                    esc_url( $project_url ),
176                    esc_html__( 'BeyondWords dashboard', 'speechkit' )
177                )
178            );
179            ?>
180        </div>
181        <?php
182    }
183
184    /**
185     * Embed a player for a WordPress post.
186     *
187     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
188     *
189     * @param int|\WP_Post|null $post (Optional) Post ID, or WP_Post object, or null.
190     *
191     * @since 3.x   Introduced
192     * @since 4.0.1 Admin player init is now all in this one function.
193     * @since 6.0.0 Make static and add Magic Embed support.
194     */
195    public static function player_embed( $post = null ) {
196        $post = get_post( $post );
197
198        if ( ! ( $post instanceof \WP_Post ) ) {
199            return;
200        }
201
202        $project_id  = \BeyondWords\Post\Meta::get_project_id( $post->ID );
203        $has_content = \BeyondWords\Post\Meta::has_content( $post->ID );
204
205        if ( ! $project_id || ! $has_content ) {
206            return;
207        }
208
209        $content_id    = \BeyondWords\Post\Meta::get_content_id( $post->ID );
210        $preview_token = \BeyondWords\Post\Meta::get_preview_token( $post->ID );
211
212        // phpcs:disable WordPress.WP.EnqueuedResources.NonEnqueuedScript
213        if ( ! empty( $content_id ) ) :
214            /*
215             * Still-processing content would 404 (and the CDN would cache it), so
216             * render a loading state and let classic-metabox.js poll until `processed`.
217             * The untrusted content ID / preview token travel as esc_attr()'d data-*
218             * attributes — never interpolated into a JS execution context.
219             */
220            ?>
221            <div
222                id="beyondwords-metabox-player"
223                role="status"
224                aria-live="polite"
225                style="margin: 13px 0;"
226                data-project-id="<?php echo esc_attr( $project_id ); ?>"
227                data-content-id="<?php echo esc_attr( $content_id ); ?>"
228                data-preview-token="<?php echo esc_attr( $preview_token ); ?>"
229            >
230                <span
231                    class="spinner is-active"
232                    style="float: none; margin: 0 8px 0 0;"
233                ></span>
234                <span class="beyondwords-player-loading-text">
235                    <?php esc_html_e( 'Generating…', 'speechkit' ); ?>
236                </span>
237            </div>
238            <script
239                defer
240                src='<?php echo esc_url( \BeyondWords\Core\Urls::get_js_sdk_url() ); ?>'
241            ></script>
242            <?php
243        else :
244            /*
245             * Client-side integration is keyed on the source ID — nothing to poll, so
246             * embed immediately. The untrusted preview token is JSON_HEX_*-encoded so
247             * no value breaks out of the onload attribute. Mirrors Javascript::render().
248             */
249            $config = [
250                'projectId'        => (int) $project_id,
251                'sourceId'         => (string) $post->ID,
252                'previewToken'     => (string) $preview_token,
253                'adverts'          => [],
254                'analyticsConsent' => 'none',
255                'introsOutros'     => [],
256                'playerStyle'      => 'small',
257                'widgetStyle'      => 'none',
258            ];
259
260            $onload = sprintf(
261                'const player = new BeyondWords.Player({ target: this.parentElement, ...%s });',
262                wp_json_encode(
263                    $config,
264                    JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP
265                )
266            );
267            ?>
268            <div id="beyondwords-metabox-player" style="margin: 13px 0;">
269            <script defer
270                src='<?php echo esc_url( \BeyondWords\Core\Urls::get_js_sdk_url() ); ?>'
271                onload='<?php echo esc_attr( $onload ); ?>'
272            >
273            </script>
274            </div>
275            <?php
276        endif;
277        // phpcs:enable WordPress.WP.EnqueuedResources.NonEnqueuedScript
278    }
279
280    /**
281     * Render a loading state while deferred audio generation is still queued.
282     *
283     * See doc/async-rest-migration.md.
284     *
285     * @since 7.0.0
286     *
287     * @param int|\WP_Post|null $post (Optional) Post ID, or WP_Post object, or null.
288     */
289    public static function awaiting_generation_embed( $post = null ) {
290        $post = get_post( $post );
291
292        if ( ! ( $post instanceof \WP_Post ) ) {
293            return;
294        }
295
296        // No queued job means no audio is coming, so a spinner would never resolve.
297        if ( ! wp_next_scheduled( \BeyondWords\Post\Sync::GENERATE_AUDIO_CRON_HOOK, [ $post->ID ] ) ) {
298            return;
299        }
300
301        $project_id = \BeyondWords\Post\Meta::get_project_id( $post->ID );
302
303        if ( ! $project_id ) {
304            return;
305        }
306
307        // phpcs:disable WordPress.WP.EnqueuedResources.NonEnqueuedScript
308        ?>
309        <div
310            id="beyondwords-metabox-player"
311            role="status"
312            aria-live="polite"
313            style="margin: 13px 0;"
314            data-project-id="<?php echo esc_attr( $project_id ); ?>"
315            data-post-id="<?php echo esc_attr( $post->ID ); ?>"
316            data-await-content="1"
317        >
318            <span
319                class="spinner is-active"
320                style="float: none; margin: 0 8px 0 0;"
321            ></span>
322            <span class="beyondwords-player-loading-text">
323                <?php esc_html_e( 'Generating…', 'speechkit' ); ?>
324            </span>
325        </div>
326        <script
327            defer
328            src='<?php echo esc_url( \BeyondWords\Core\Urls::get_js_sdk_url() ); ?>'
329        ></script>
330        <?php
331        // phpcs:enable WordPress.WP.EnqueuedResources.NonEnqueuedScript
332    }
333
334    /**
335     * Display errors for the post.
336     *
337     * @since 6.0.0 Make static.
338     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
339     */
340    public static function errors( $post ) {
341        $error = \BeyondWords\Post\Meta::get_error_message( $post->ID );
342
343        if ( $error ) :
344            ?>
345            <div id="beyondwords-metabox-errors">
346                <div class="beyondwords-error">
347                    <p>
348                        <?php echo esc_html( $error ); ?>
349                    </p>
350                </div>
351                <?php self::regenerate_instructions(); ?>
352            </div>
353            <?php
354        endif;
355    }
356
357    /**
358     * Display help text for the metabox.
359     *
360     * @since 6.0.0 Make static.
361     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
362     */
363    public static function help() {
364        ?>
365        <p id="beyondwords-metabox-help">
366            <?php
367            printf(
368                /* translators: %s is replaced with the link to the support email address */
369                esc_html__( 'Need help? Email our support team on %s', 'speechkit' ),
370                sprintf( '<a href="%s">%s</a>', 'mailto:support@beyondwords.io', 'support@beyondwords.io' )
371            );
372            ?>
373        </p>
374        <?php
375    }
376
377    /**
378     * Display instructions for regenerating audio.
379     *
380     * @since 6.0.0 Make static.
381     * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
382     */
383    public static function regenerate_instructions() {
384        ?>
385        <!-- Update/regenerate -->
386        <p>
387            <?php
388            esc_html_e(
389                'To create audio, resolve the error above then select ‘Update’ with ‘Generate audio’ checked.', // phpcs:ignore Generic.Files.LineLength.TooLong
390                'speechkit'
391            );
392            ?>
393        </p>
394        <?php
395    }
396}