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