Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.33% covered (success)
97.33%
255 / 262
73.91% covered (warning)
73.91%
17 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
SettingsFields
97.32% covered (success)
97.32%
254 / 261
73.91% covered (warning)
73.91%
17 / 23
74
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 nonce
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 project_default_option
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 source_options
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 output_options
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 source_includes_post
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 source_includes_script
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 output_includes_audio
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 output_includes_video
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 embed_options
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
7
 is_embed_valid
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 default_embed
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 get_effective_embed
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
5.03
 is_player_disabled_for_post
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 render_content_section
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
2
 render_format_section
97.22% covered (success)
97.22%
35 / 36
0.00% covered (danger)
0.00%
0 / 1
5
 render_player_section
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 templates_to_options
88.89% covered (success)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 sizes_to_options
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
6.02
 render_select
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
4
 get_meta
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
4
 save
92.86% covered (success)
92.86%
26 / 28
0.00% covered (danger)
0.00%
0 / 1
10.04
 is_valid_meta_value
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
7
1<?php
2
3declare( strict_types = 1 );
4
5/**
6 * BeyondWords Component: Settings Fields (Classic editor).
7 *
8 * Classic-editor counterparts of the block editor's Content/Format/Player
9 * sections; dynamic behaviour lives in classic-metabox.js (mirrors helpers.js).
10 *
11 * @package BeyondWords\Editor\Components
12 * @author  Stuart McAlpine <stu@beyondwords.io>
13 * @since   7.0.0
14 */
15
16namespace BeyondWords\Editor\Components;
17
18/**
19 * SettingsFields
20 *
21 * @since 7.0.0
22 */
23defined( 'ABSPATH' ) || exit;
24
25class SettingsFields {
26
27    public const SOURCE_POST            = 'post';
28    public const SOURCE_SCRIPT          = 'script';
29    public const SOURCE_POST_AND_SCRIPT = 'post_and_script';
30
31    public const OUTPUT_AUDIO           = 'audio';
32    public const OUTPUT_VIDEO           = 'video';
33    public const OUTPUT_AUDIO_AND_VIDEO = 'audio_and_video';
34
35    public const EMBED_NONE         = 'none';
36    public const EMBED_AUDIO_POST   = 'audio_post';
37    public const EMBED_AUDIO_SCRIPT = 'audio_script';
38    public const EMBED_VIDEO_POST   = 'video_post';
39    public const EMBED_VIDEO_SCRIPT = 'video_script';
40
41    /**
42     * Init.
43     *
44     * @since 7.0.0
45     */
46    public static function init() {
47        add_action(
48            'wp_loaded',
49            function (): void {
50                $post_types = \BeyondWords\Settings\Utils::get_compatible_post_types();
51
52                if ( is_array( $post_types ) ) {
53                    foreach ( $post_types as $post_type ) {
54                        add_action( "save_post_{$post_type}", [ self::class, 'save' ], 10 );
55                    }
56                }
57            }
58        );
59    }
60
61    /**
62     * Render the nonce field shared by all Settings Fields sections.
63     *
64     * Called once by the metabox so a single nonce guards the combined
65     * Content/Format/Player save.
66     *
67     * @since 7.0.0
68     */
69    public static function nonce(): void {
70        wp_nonce_field( 'beyondwords_settings_fields', 'beyondwords_settings_fields_nonce' );
71    }
72
73    // Option helpers (mirror src/editor/components/settings-panel/helpers.js).
74
75    /**
76     * The "Project default" leaf option.
77     *
78     * An empty value defers to the project setting — the plugin omits the field
79     * from the content payload when empty.
80     *
81     * @since 7.0.0
82     *
83     * @return array{label: string, value: string}
84     */
85    public static function project_default_option(): array {
86        return [
87            'label' => __( 'Project default', 'speechkit' ),
88            'value' => '',
89        ];
90    }
91
92    /**
93     * Source dropdown options.
94     *
95     * @since 7.0.0
96     *
97     * @return array<array{label: string, value: string}>
98     */
99    public static function source_options(): array {
100        return [
101            [
102                'label' => __( 'Post', 'speechkit' ),
103                'value' => self::SOURCE_POST,
104            ],
105            [
106                'label' => __( 'Script', 'speechkit' ),
107                'value' => self::SOURCE_SCRIPT,
108            ],
109            [
110                'label' => __( 'Post + script', 'speechkit' ),
111                'value' => self::SOURCE_POST_AND_SCRIPT,
112            ],
113        ];
114    }
115
116    /**
117     * Output dropdown options.
118     *
119     * @since 7.0.0
120     *
121     * @return array<array{label: string, value: string}>
122     */
123    public static function output_options(): array {
124        return [
125            [
126                'label' => __( 'Audio', 'speechkit' ),
127                'value' => self::OUTPUT_AUDIO,
128            ],
129            [
130                'label' => __( 'Video', 'speechkit' ),
131                'value' => self::OUTPUT_VIDEO,
132            ],
133            [
134                'label' => __( 'Audio + video', 'speechkit' ),
135                'value' => self::OUTPUT_AUDIO_AND_VIDEO,
136            ],
137        ];
138    }
139
140    /**
141     * Whether the source includes the post body.
142     *
143     * @since 7.0.0
144     *
145     * @param string $source One of the SOURCE_* constants.
146     */
147    public static function source_includes_post( string $source ): bool {
148        return in_array( $source, [ self::SOURCE_POST, self::SOURCE_POST_AND_SCRIPT ], true );
149    }
150
151    /**
152     * Whether the source includes a generated script.
153     *
154     * @since 7.0.0
155     *
156     * @param string $source One of the SOURCE_* constants.
157     */
158    public static function source_includes_script( string $source ): bool {
159        return in_array( $source, [ self::SOURCE_SCRIPT, self::SOURCE_POST_AND_SCRIPT ], true );
160    }
161
162    /**
163     * Whether the output includes audio.
164     *
165     * @since 7.0.0
166     *
167     * @param string $output One of the OUTPUT_* constants.
168     */
169    public static function output_includes_audio( string $output ): bool {
170        return in_array( $output, [ self::OUTPUT_AUDIO, self::OUTPUT_AUDIO_AND_VIDEO ], true );
171    }
172
173    /**
174     * Whether the output includes video.
175     *
176     * @since 7.0.0
177     *
178     * @param string $output One of the OUTPUT_* constants.
179     */
180    public static function output_includes_video( string $output ): bool {
181        return in_array( $output, [ self::OUTPUT_VIDEO, self::OUTPUT_AUDIO_AND_VIDEO ], true );
182    }
183
184    /**
185     * Derive the valid "Embed" dropdown options from the current Source × Output.
186     *
187     * Returns None plus one entry for each asset combination the current
188     * source/output would produce.
189     *
190     * @since 7.0.0
191     *
192     * @param string $source One of the SOURCE_* constants.
193     * @param string $output One of the OUTPUT_* constants.
194     *
195     * @return array<array{label: string, value: string}>
196     */
197    public static function embed_options( string $source, string $output ): array {
198        $options = [
199            [
200                'label' => __( 'None', 'speechkit' ),
201                'value' => self::EMBED_NONE,
202            ],
203        ];
204
205        if ( self::output_includes_audio( $output ) ) {
206            if ( self::source_includes_post( $source ) ) {
207                $options[] = [
208                    'label' => __( 'Audio (post)', 'speechkit' ),
209                    'value' => self::EMBED_AUDIO_POST,
210                ];
211            }
212            if ( self::source_includes_script( $source ) ) {
213                $options[] = [
214                    'label' => __( 'Audio (script)', 'speechkit' ),
215                    'value' => self::EMBED_AUDIO_SCRIPT,
216                ];
217            }
218        }
219
220        if ( self::output_includes_video( $output ) ) {
221            if ( self::source_includes_post( $source ) ) {
222                $options[] = [
223                    'label' => __( 'Video (post)', 'speechkit' ),
224                    'value' => self::EMBED_VIDEO_POST,
225                ];
226            }
227            if ( self::source_includes_script( $source ) ) {
228                $options[] = [
229                    'label' => __( 'Video (script)', 'speechkit' ),
230                    'value' => self::EMBED_VIDEO_SCRIPT,
231                ];
232            }
233        }
234
235        return $options;
236    }
237
238    /**
239     * Whether the given embed value is selectable for the current Source × Output.
240     *
241     * @since 7.0.0
242     *
243     * @param string $embed  One of the EMBED_* constants.
244     * @param string $source One of the SOURCE_* constants.
245     * @param string $output One of the OUTPUT_* constants.
246     */
247    public static function is_embed_valid( string $embed, string $source, string $output ): bool {
248        foreach ( self::embed_options( $source, $output ) as $option ) {
249            if ( $option['value'] === $embed ) {
250                return true;
251            }
252        }
253
254        return false;
255    }
256
257    /**
258     * The default Embed for a post that hasn't chosen one: the first produced asset.
259     *
260     * Keeps the player visible by default — "None" is the deliberate opt-out.
261     * Mirrors getDefaultEmbed() in settings-panel/helpers.js.
262     *
263     * @since 7.0.0
264     *
265     * @param string $source One of the SOURCE_* constants.
266     * @param string $output One of the OUTPUT_* constants.
267     *
268     * @return string The default embed value.
269     */
270    public static function default_embed( string $source, string $output ): string {
271        foreach ( self::embed_options( $source, $output ) as $option ) {
272            if ( self::EMBED_NONE !== $option['value'] ) {
273                return $option['value'];
274            }
275        }
276
277        return self::EMBED_NONE;
278    }
279
280    /**
281     * Resolve the effective Embed value for a post.
282     *
283     * Centralised so the shown default and the rendered player (ConfigBuilder)
284     * never diverge; legacy opt-outs and no-longer-valid values resolve to None.
285     *
286     * @since 7.0.0
287     *
288     * @param int $post_id The post ID.
289     *
290     * @return string One of the EMBED_* constants.
291     */
292    public static function get_effective_embed( int $post_id ): string {
293        $source = self::get_meta( $post_id, 'beyondwords_source', self::SOURCE_POST );
294        $output = self::get_meta( $post_id, 'beyondwords_output', self::OUTPUT_AUDIO );
295        $embed  = get_post_meta( $post_id, 'beyondwords_embed', true );
296
297        if ( ! is_string( $embed ) || '' === $embed ) {
298            $embed = \BeyondWords\Post\Meta::get_disabled( $post_id )
299                ? self::EMBED_NONE
300                : self::default_embed( $source, $output );
301        }
302
303        if ( ! self::is_embed_valid( $embed, $source, $output ) ) {
304            $embed = self::EMBED_NONE;
305        }
306
307        return $embed;
308    }
309
310    /**
311     * Whether the player is suppressed on a post.
312     *
313     * An explicit Embed value is authoritative ("None" replaces the pre-v7 opt-out);
314     * only when unset do we fall back to the legacy `beyondwords_disabled` flag.
315     *
316     * @since 7.0.0
317     *
318     * @param int $post_id The post ID.
319     */
320    public static function is_player_disabled_for_post( int $post_id ): bool {
321        $embed = get_post_meta( $post_id, 'beyondwords_embed', true );
322
323        if ( is_string( $embed ) && '' !== $embed ) {
324            return self::EMBED_NONE === $embed;
325        }
326
327        return (bool) \BeyondWords\Post\Meta::get_disabled( $post_id );
328    }
329
330    // Renderers.
331
332    /**
333     * Render the Content section fields: Source + Script template.
334     *
335     * @since 7.0.0
336     *
337     * @param \WP_Post $post The post object.
338     */
339    public static function render_content_section( $post ): void {
340        $source             = self::get_meta( $post->ID, 'beyondwords_source', self::SOURCE_POST );
341        $script_template_id = self::get_meta( $post->ID, 'beyondwords_script_template_id', '' );
342
343        $templates = \BeyondWords\Api\Client::get_summarization_settings_templates();
344        $templates = is_array( $templates ) ? $templates : [];
345
346        self::render_select(
347            'beyondwords_source',
348            __( 'Source', 'speechkit' ),
349            self::source_options(),
350            $source
351        );
352
353        self::render_select(
354            'beyondwords_script_template_id',
355            __( 'Script template', 'speechkit' ),
356            array_merge(
357                [ self::project_default_option() ],
358                self::templates_to_options( $templates )
359            ),
360            $script_template_id,
361            ! self::source_includes_script( $source )
362        );
363    }
364
365    /**
366     * Render the Format section fields: Output + Video template + Video size.
367     *
368     * @since 7.0.0
369     *
370     * @param \WP_Post $post The post object.
371     */
372    public static function render_format_section( $post ): void {
373        $output            = self::get_meta( $post->ID, 'beyondwords_output', self::OUTPUT_AUDIO );
374        $video_template_id = self::get_meta( $post->ID, 'beyondwords_video_template_id', '' );
375        $video_size        = self::get_meta( $post->ID, 'beyondwords_video_size', '' );
376
377        $templates = \BeyondWords\Api\Client::get_video_settings_templates();
378        $templates = is_array( $templates ) ? $templates : [];
379
380        $video_settings = \BeyondWords\Api\Client::get_video_settings();
381        $sizes          = is_array( $video_settings ) && isset( $video_settings['sizes'] ) && is_array( $video_settings['sizes'] )
382            ? $video_settings['sizes']
383            : [];
384
385        $hide_video = ! self::output_includes_video( $output );
386
387        self::render_select(
388            'beyondwords_output',
389            __( 'Output', 'speechkit' ),
390            self::output_options(),
391            $output
392        );
393
394        self::render_select(
395            'beyondwords_video_template_id',
396            __( 'Video template', 'speechkit' ),
397            array_merge(
398                [ self::project_default_option() ],
399                self::templates_to_options( $templates )
400            ),
401            $video_template_id,
402            $hide_video
403        );
404
405        self::render_select(
406            'beyondwords_video_size',
407            __( 'Video size', 'speechkit' ),
408            array_merge(
409                [ self::project_default_option() ],
410                self::sizes_to_options( $sizes )
411            ),
412            $video_size,
413            $hide_video
414        );
415    }
416
417    /**
418     * Render the Player section fields: Embed.
419     *
420     * @since 7.0.0
421     *
422     * @param \WP_Post $post The post object.
423     */
424    public static function render_player_section( $post ): void {
425        $source = self::get_meta( $post->ID, 'beyondwords_source', self::SOURCE_POST );
426        $output = self::get_meta( $post->ID, 'beyondwords_output', self::OUTPUT_AUDIO );
427        $embed  = self::get_effective_embed( $post->ID );
428
429        self::render_select(
430            'beyondwords_embed',
431            __( 'Embed', 'speechkit' ),
432            self::embed_options( $source, $output ),
433            $embed,
434            false,
435            __(
436                'Pick which generated asset is shown on this post. All other generated assets stay available in BeyondWords.', // phpcs:ignore Generic.Files.LineLength.TooLong
437                'speechkit'
438            )
439        );
440    }
441
442    /**
443     * Convert a list of API templates to <select> options.
444     *
445     * @since 7.0.0
446     *
447     * @param array<array<string, mixed>> $templates API templates.
448     *
449     * @return array<array{label: string, value: string}>
450     */
451    private static function templates_to_options( array $templates ): array {
452        $options = [];
453
454        foreach ( $templates as $template ) {
455            if ( ! isset( $template['id'] ) ) {
456                continue;
457            }
458
459            $options[] = [
460                'label' => (string) ( $template['name'] ?? $template['slug'] ?? '' ),
461                'value' => (string) $template['id'],
462            ];
463        }
464
465        return $options;
466    }
467
468    /**
469     * Convert a list of API video sizes to <select> options.
470     *
471     * @since 7.0.0
472     *
473     * @param array<array<string, mixed>> $sizes API video sizes.
474     *
475     * @return array<array{label: string, value: string}>
476     */
477    private static function sizes_to_options( array $sizes ): array {
478        $options = [];
479
480        foreach ( $sizes as $size ) {
481            if ( ! isset( $size['name'] ) || ( isset( $size['enabled'] ) && false === $size['enabled'] ) ) {
482                continue;
483            }
484
485            $label = ! empty( $size['description'] )
486                ? sprintf( '%s (%s)', $size['name'], $size['description'] )
487                : (string) $size['name'];
488
489            $options[] = [
490                'label' => $label,
491                'value' => (string) $size['name'],
492            ];
493        }
494
495        return $options;
496    }
497
498    /**
499     * Render a labelled <select> control in the classic-metabox style.
500     *
501     * @since 7.0.0
502     *
503     * @param string                                     $id       Field id/name.
504     * @param string                                     $label    Field label.
505     * @param array<array{label: string, value: string}> $options Select options.
506     * @param string                                     $selected Selected value.
507     * @param bool                                       $hidden   Whether the field starts hidden.
508     * @param string                                     $help     Optional help text.
509     */
510    private static function render_select(
511        string $id,
512        string $label,
513        array $options,
514        string $selected,
515        bool $hidden = false,
516        string $help = ''
517    ): void {
518        $wrapper_id = 'beyondwords-metabox-settings--' . str_replace( '_', '-', $id );
519        ?>
520        <div
521            id="<?php echo esc_attr( $wrapper_id ); ?>"
522            class="beyondwords-metabox-settings__field"
523            <?php echo $hidden ? 'style="display: none;"' : ''; ?>
524        >
525            <p class="post-attributes-label-wrapper page-template-label-wrapper">
526                <label class="post-attributes-label" for="<?php echo esc_attr( $id ); ?>">
527                    <?php echo esc_html( $label ); ?>
528                </label>
529            </p>
530            <select id="<?php echo esc_attr( $id ); ?>" name="<?php echo esc_attr( $id ); ?>" style="width: 100%;">
531                <?php
532                foreach ( $options as $option ) {
533                    printf(
534                        '<option value="%s" %s>%s</option>',
535                        esc_attr( $option['value'] ),
536                        selected( strval( $option['value'] ), strval( $selected ), false ),
537                        esc_html( $option['label'] )
538                    );
539                }
540                ?>
541            </select>
542            <?php if ( $help ) : ?>
543                <p class="description" style="margin-top: 4px;"><?php echo esc_html( $help ); ?></p>
544            <?php endif; ?>
545        </div>
546        <?php
547    }
548
549    /**
550     * Read a post-meta value, falling back to a default when empty.
551     *
552     * @since 7.0.0
553     *
554     * @param int    $post_id  The post ID.
555     * @param string $key      The meta key.
556     * @param string $fallback The default value.
557     */
558    private static function get_meta( int $post_id, string $key, string $fallback ): string {
559        $value = get_post_meta( $post_id, $key, true );
560
561        return ( '' === $value || null === $value || false === $value ) ? $fallback : (string) $value;
562    }
563
564    /**
565     * Save the Content/Format/Player meta when the post is saved.
566     *
567     * @since 7.0.0
568     *
569     * @param int $post_id The ID of the post being saved.
570     *
571     * @return int
572     */
573    public static function save( $post_id ) {
574        if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
575            return $post_id;
576        }
577
578        if (
579            ! isset( $_POST['beyondwords_settings_fields_nonce'] ) ||
580            ! wp_verify_nonce(
581                sanitize_key( $_POST['beyondwords_settings_fields_nonce'] ),
582                'beyondwords_settings_fields'
583            )
584        ) {
585            return $post_id;
586        }
587
588        // The nonce proves intent, not authorisation.
589        if ( ! current_user_can( 'edit_post', $post_id ) ) {
590            return $post_id;
591        }
592
593        $keys = [
594            'beyondwords_source',
595            'beyondwords_script_template_id',
596            'beyondwords_output',
597            'beyondwords_video_template_id',
598            'beyondwords_video_size',
599            'beyondwords_embed',
600        ];
601
602        foreach ( $keys as $key ) {
603            if ( ! isset( $_POST[ $key ] ) ) {
604                continue;
605            }
606
607            $value = sanitize_text_field( wp_unslash( $_POST[ $key ] ) );
608
609            if ( '' === $value ) {
610                delete_post_meta( $post_id, $key );
611                continue;
612            }
613
614            // Reject anything outside the known option set.
615            if ( self::is_valid_meta_value( $key, $value ) ) {
616                update_post_meta( $post_id, $key, $value );
617            }
618        }
619
620        return $post_id;
621    }
622
623    /**
624     * Whether a submitted value is allowed for the given Settings Fields key.
625     *
626     * The free-form video size (an API-supplied name) only needs the
627     * sanitisation already applied by the caller.
628     *
629     * @since 7.0.0
630     *
631     * @param string $key   The meta key.
632     * @param string $value The sanitised, non-empty submitted value.
633     */
634    private static function is_valid_meta_value( string $key, string $value ): bool {
635        switch ( $key ) {
636            case 'beyondwords_source':
637                return in_array( $value, array_column( self::source_options(), 'value' ), true );
638            case 'beyondwords_output':
639                return in_array( $value, array_column( self::output_options(), 'value' ), true );
640            case 'beyondwords_embed':
641                return in_array(
642                    $value,
643                    [
644                        self::EMBED_NONE,
645                        self::EMBED_AUDIO_POST,
646                        self::EMBED_AUDIO_SCRIPT,
647                        self::EMBED_VIDEO_POST,
648                        self::EMBED_VIDEO_SCRIPT,
649                    ],
650                    true
651                );
652            case 'beyondwords_script_template_id':
653            case 'beyondwords_video_template_id':
654                return ctype_digit( $value );
655            default:
656                return true;
657        }
658    }
659}