Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.07% covered (success)
98.07%
203 / 207
73.33% covered (warning)
73.33%
11 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Preselect
98.07% covered (success)
98.07%
203 / 207
73.33% covered (warning)
73.33%
11 / 15
76
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 enqueue_assets
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 register
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 get
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 get_mode
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
10.04
 get_selected_terms
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
11.02
 should_preselect_for_post
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
11
 sanitize
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
9
 sanitize_terms
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
9
 render
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
4.01
 render_post_type
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
6
 render_term_tree
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 render_term_branch
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
3
 get_hierarchical_taxonomies
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 get_hierarchical_taxonomy_names
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Preselect setting.
4 *
5 * @package BeyondWords\Settings
6 *
7 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
8 * @since 7.0.0 Reinstated term-gating, stored in a mode-based format.
9 */
10
11declare( strict_types = 1 );
12
13namespace BeyondWords\Settings;
14
15defined( 'ABSPATH' ) || exit;
16
17/**
18 * Preselect setting, stored as a per-post-type map with a `mode` key.
19 *
20 * `all` preselects every post; `terms` only posts with a listed term ID;
21 * absent means off. Pre-7.0.0 shapes are migrated by `Updater::run()`.
22 *
23 * @since 7.0.0
24 */
25class Preselect {
26
27    const OPTION_NAME = 'beyondwords_preselect';
28
29    const MODE_OFF   = 'off';
30    const MODE_ALL   = 'all';
31    const MODE_TERMS = 'terms';
32
33    const DEFAULT_VALUE = [
34        'post' => [ 'mode' => self::MODE_ALL ],
35    ];
36
37    /**
38     * Register WordPress hooks.
39     */
40    public static function init(): void {
41        add_action( 'admin_init', [ self::class, 'register' ] );
42        add_action( 'admin_enqueue_scripts', [ self::class, 'enqueue_assets' ] );
43    }
44
45    /**
46     * Enqueue the preselect progressive-disclosure script on the settings page.
47     *
48     * @since 7.0.0
49     *
50     * @param string $hook Current admin page hook.
51     */
52    public static function enqueue_assets( $hook ): void {
53        if ( 'settings_page_' . Settings::PAGE_SLUG !== $hook ) {
54            return;
55        }
56
57        wp_enqueue_script(
58            'beyondwords-settings--preselect',
59            BEYONDWORDS__PLUGIN_URI . 'src/settings/preselect.js',
60            [],
61            BEYONDWORDS__PLUGIN_VERSION,
62            true
63        );
64    }
65
66    /**
67     * Register the option and settings field.
68     */
69    public static function register(): void {
70        register_setting(
71            Tabs::SETTINGS_GROUP_PREFERENCES,
72            self::OPTION_NAME,
73            [
74                'type'              => 'object',
75                'default'           => self::DEFAULT_VALUE,
76                'sanitize_callback' => [ self::class, 'sanitize' ],
77            ]
78        );
79
80        add_settings_field(
81            'beyondwords-preselect',
82            __( 'Preselect ‘Generate audio’', 'speechkit' ),
83            [ self::class, 'render' ],
84            Tabs::PAGE_PREFERENCES,
85            Tabs::SECTION_PREFERENCES
86        );
87    }
88
89    /**
90     * Read the current preselect map.
91     *
92     * @return array<string,mixed>
93     */
94    public static function get(): array {
95        $preselect = get_option( self::OPTION_NAME, self::DEFAULT_VALUE );
96        return is_array( $preselect ) ? $preselect : [];
97    }
98
99    /**
100     * Resolve the preselect mode for a post type.
101     *
102     * Tolerant of pre-7.0.0 shapes so behaviour is correct before the migration
103     * runs: `'1'` reads as `all`, a non-empty taxonomy array as `terms`.
104     *
105     * @param string                   $post_type Post type slug.
106     * @param array<string,mixed>|null $preselect Pre-loaded option, to avoid an extra `get_option()`.
107     *
108     * @return string One of `off`, `all`, `terms`.
109     */
110    public static function get_mode( string $post_type, ?array $preselect = null ): string {
111        if ( null === $preselect ) {
112            $preselect = self::get();
113        }
114
115        if ( ! array_key_exists( $post_type, $preselect ) ) {
116            return self::MODE_OFF;
117        }
118
119        $value = $preselect[ $post_type ];
120
121        // Legacy whole-post-type flag.
122        if ( '1' === $value || 1 === $value || true === $value ) {
123            return self::MODE_ALL;
124        }
125
126        if ( is_array( $value ) ) {
127            if ( isset( $value['mode'] ) ) {
128                return in_array( $value['mode'], [ self::MODE_ALL, self::MODE_TERMS ], true )
129                    ? $value['mode']
130                    : self::MODE_OFF;
131            }
132
133            // Legacy term-gated shape: [ taxonomy => [ term ids ] ].
134            return empty( $value ) ? self::MODE_OFF : self::MODE_TERMS;
135        }
136
137        return self::MODE_OFF;
138    }
139
140    /**
141     * The selected term IDs for a post type, keyed by taxonomy.
142     *
143     * Reads both the new (`terms` key) and legacy (bare taxonomy array) shapes.
144     * Returns `[]` for any mode other than `terms`.
145     *
146     * @param string                   $post_type Post type slug.
147     * @param array<string,mixed>|null $preselect Pre-loaded option.
148     *
149     * @return array<string,int[]> Map of taxonomy slug to term IDs.
150     */
151    public static function get_selected_terms( string $post_type, ?array $preselect = null ): array {
152        if ( null === $preselect ) {
153            $preselect = self::get();
154        }
155
156        if ( ! isset( $preselect[ $post_type ] ) || ! is_array( $preselect[ $post_type ] ) ) {
157            return [];
158        }
159
160        $value = $preselect[ $post_type ];
161
162        if ( isset( $value['mode'] ) ) {
163            if ( self::MODE_TERMS !== $value['mode'] || ! isset( $value['terms'] ) || ! is_array( $value['terms'] ) ) {
164                return [];
165            }
166            $raw = $value['terms'];
167        } else {
168            // Legacy [ taxonomy => [ term ids ] ].
169            $raw = $value;
170        }
171
172        $terms = [];
173
174        foreach ( $raw as $taxonomy => $ids ) {
175            if ( ! is_array( $ids ) ) {
176                continue;
177            }
178
179            $clean = array_values( array_filter( array_map( 'intval', $ids ) ) );
180
181            if ( ! empty( $clean ) ) {
182                $terms[ (string) $taxonomy ] = $clean;
183            }
184        }
185
186        return $terms;
187    }
188
189    /**
190     * Whether "Generate audio" should be preselected for a given post.
191     *
192     * In `terms` mode a post matches when it has at least one listed term (OR
193     * across taxonomies); unregistered/detached taxonomies are skipped, never fatal.
194     *
195     * @param \WP_Post|int $post Post object or ID.
196     */
197    public static function should_preselect_for_post( $post ): bool {
198        $post_type = get_post_type( $post );
199
200        if ( ! $post_type ) {
201            return false;
202        }
203
204        // get()'s DEFAULT_VALUE fallback applies in every context (REST, cron),
205        // keeping the server's decision in step with the editor's derived toggle.
206        $preselect = self::get();
207
208        $mode = self::get_mode( $post_type, $preselect );
209
210        if ( self::MODE_ALL === $mode ) {
211            return true;
212        }
213
214        if ( self::MODE_TERMS !== $mode ) {
215            return false;
216        }
217
218        $selected = self::get_selected_terms( $post_type, $preselect );
219
220        if ( empty( $selected ) ) {
221            return false;
222        }
223
224        $post_type_object_taxonomies = get_object_taxonomies( $post_type );
225
226        foreach ( $selected as $taxonomy => $term_ids ) {
227            if ( ! taxonomy_exists( $taxonomy ) || ! in_array( $taxonomy, $post_type_object_taxonomies, true ) ) {
228                continue;
229            }
230
231            $post_terms = get_the_terms( $post, $taxonomy );
232
233            if ( empty( $post_terms ) || is_wp_error( $post_terms ) ) {
234                continue;
235            }
236
237            $post_term_ids = array_map( 'intval', wp_list_pluck( $post_terms, 'term_id' ) );
238
239            if ( array_intersect( $term_ids, $post_term_ids ) ) {
240                return true;
241            }
242        }
243
244        return false;
245    }
246
247    /**
248     * Sanitise the submitted preselect map.
249     *
250     * Merge-preserve: only post types/taxonomies rendered this request are read
251     * from the submission, so a save never wipes a deactivated plugin's config.
252     *
253     * @param mixed $value Raw submitted value.
254     *
255     * @return array<string,mixed>
256     */
257    public static function sanitize( $value ): array {
258        // Merge from the RAW stored option — get()'s DEFAULT_VALUE fallback
259        // would leak `post => all` into a fresh save.
260        $raw      = get_option( self::OPTION_NAME );
261        $existing = is_array( $raw ) ? $raw : [];
262
263        if ( ! is_array( $value ) ) {
264            return $existing;
265        }
266
267        $clean = $existing;
268
269        foreach ( Utils::get_compatible_post_types() as $post_type ) {
270            $submitted = ( isset( $value[ $post_type ] ) && is_array( $value[ $post_type ] ) ) ? $value[ $post_type ] : [];
271
272            if ( empty( $submitted['enabled'] ) ) {
273                unset( $clean[ $post_type ] );
274                continue;
275            }
276
277            $has_taxonomies = ! empty( self::get_hierarchical_taxonomy_names( $post_type ) );
278
279            // "All" wins over any ticked terms; with no hierarchical taxonomies
280            // the whole-post-type option is the only choice.
281            if ( ! $has_taxonomies || ! empty( $submitted['all'] ) ) {
282                $clean[ $post_type ] = [ 'mode' => self::MODE_ALL ];
283                continue;
284            }
285
286            $clean[ $post_type ] = [
287                'mode'  => self::MODE_TERMS,
288                'terms' => self::sanitize_terms( $post_type, $submitted, $existing ),
289            ];
290        }
291
292        return $clean;
293    }
294
295    /**
296     * Sanitise one post type's term map, merge-preserving unrendered taxonomies.
297     *
298     * @param string              $post_type Post type slug.
299     * @param array<string,mixed> $submitted Submitted value for this post type.
300     * @param array<string,mixed> $existing  Full stored option (for preservation).
301     *
302     * @return array<string,int[]>
303     */
304    private static function sanitize_terms( string $post_type, array $submitted, array $existing ): array {
305        $rendered_taxonomies = self::get_hierarchical_taxonomy_names( $post_type );
306        $submitted_terms     = ( isset( $submitted['terms'] ) && is_array( $submitted['terms'] ) ) ? $submitted['terms'] : [];
307
308        $terms = [];
309
310        // Preserve stored terms for taxonomies that weren't rendered this request.
311        foreach ( self::get_selected_terms( $post_type, $existing ) as $taxonomy => $ids ) {
312            if ( ! in_array( $taxonomy, $rendered_taxonomies, true ) ) {
313                $terms[ $taxonomy ] = $ids;
314            }
315        }
316
317        // Take rendered taxonomies' terms from the submission (authoritative).
318        foreach ( $rendered_taxonomies as $taxonomy ) {
319            if ( ! isset( $submitted_terms[ $taxonomy ] ) || ! is_array( $submitted_terms[ $taxonomy ] ) ) {
320                continue;
321            }
322
323            $ids = array_values( array_filter( array_map( 'intval', $submitted_terms[ $taxonomy ] ) ) );
324
325            if ( ! empty( $ids ) ) {
326                $terms[ $taxonomy ] = $ids;
327            }
328        }
329
330        return $terms;
331    }
332
333    /**
334     * Render the per-post-type controls.
335     */
336    public static function render(): void {
337        $post_types = Utils::get_compatible_post_types();
338
339        if ( empty( $post_types ) ) {
340            ?>
341            <p class="description">
342                <?php esc_html_e( 'No compatible post types found. BeyondWords requires post types that support a title, an editor, and custom fields.', 'speechkit' ); ?>
343            </p>
344            <?php
345            return;
346        }
347
348        $preselect = self::get();
349
350        foreach ( $post_types as $post_type ) {
351            $post_type_object = get_post_type_object( $post_type );
352
353            if ( ! $post_type_object ) {
354                continue;
355            }
356
357            self::render_post_type( $post_type_object, $preselect );
358        }
359    }
360
361    /**
362     * Render the control for one post type.
363     *
364     * Three nested levels (enable → "All" → term trees), progressively revealed
365     * by `preselect.js`; "All" wins over any ticked terms on save.
366     *
367     * @param \WP_Post_Type       $post_type_object Post type object.
368     * @param array<string,mixed> $preselect        Stored option.
369     */
370    private static function render_post_type( $post_type_object, array $preselect ): void {
371        $post_type      = $post_type_object->name;
372        $mode           = self::get_mode( $post_type, $preselect );
373        $selected_terms = self::get_selected_terms( $post_type, $preselect );
374        $base           = self::OPTION_NAME . '[' . $post_type . ']';
375        $taxonomies     = self::get_hierarchical_taxonomies( $post_type );
376
377        $enabled    = ( self::MODE_OFF !== $mode );
378        $is_all     = ( self::MODE_TERMS !== $mode ); // 'all' (or the default for a fresh enable).
379        $show_terms = ( $enabled && ! $is_all );
380        ?>
381        <div class="beyondwords-setting__preselect--post-type" data-post-type="<?php echo esc_attr( $post_type ); ?>" style="margin-bottom: 0.5rem;">
382            <label>
383                <input
384                    type="checkbox"
385                    class="beyondwords-setting__preselect--enabled"
386                    name="<?php echo esc_attr( $base . '[enabled]' ); ?>"
387                    value="1"
388                    <?php checked( $enabled ); ?>
389                />
390                <?php echo esc_html( $post_type_object->label ); ?>
391            </label>
392
393            <?php if ( ! empty( $taxonomies ) ) : ?>
394                <div
395                    class="beyondwords-setting__preselect--options"
396                    style="margin: 0.25rem 0 0 1.5rem;<?php echo $enabled ? '' : ' display: none;'; ?>"
397                >
398                    <label>
399                        <input
400                            type="checkbox"
401                            class="beyondwords-setting__preselect--all"
402                            name="<?php echo esc_attr( $base . '[all]' ); ?>"
403                            value="1"
404                            <?php checked( $is_all ); ?>
405                        />
406                        <?php esc_html_e( 'All', 'speechkit' ); ?>
407                    </label>
408                    <div
409                        class="beyondwords-setting__preselect--taxonomies"
410                        style="<?php echo $show_terms ? '' : 'display: none;'; ?>"
411                    >
412                        <?php foreach ( $taxonomies as $taxonomy ) : ?>
413                            <h4 style="margin: 0.5rem 0 0.5rem 1.5rem;"><?php echo esc_html( $taxonomy->label ); ?></h4>
414                            <?php
415                            $ids  = $selected_terms[ $taxonomy->name ] ?? [];
416                            $name = $base . '[terms][' . $taxonomy->name . '][]';
417                            self::render_term_tree( $taxonomy, $name, $ids );
418                            ?>
419                        <?php endforeach; ?>
420                    </div>
421                </div>
422            <?php endif; ?>
423        </div>
424        <?php
425    }
426
427    /**
428     * Render a taxonomy's terms as a nested checkbox tree.
429     *
430     * Fetches every term once and assembles the hierarchy in PHP, rather than
431     * querying per parent.
432     *
433     * @param \WP_Taxonomy $taxonomy     Taxonomy object.
434     * @param string       $name         Checkbox `name` attribute.
435     * @param int[]        $selected_ids Selected term IDs.
436     */
437    private static function render_term_tree( $taxonomy, string $name, array $selected_ids ): void {
438        $terms = get_terms(
439            [
440                'taxonomy'   => $taxonomy->name,
441                'hide_empty' => false,
442            ]
443        );
444
445        if ( empty( $terms ) || is_wp_error( $terms ) ) {
446            return;
447        }
448
449        $by_parent = [];
450        foreach ( $terms as $term ) {
451            $by_parent[ (int) $term->parent ][] = $term;
452        }
453
454        self::render_term_branch( $by_parent, 0, $name, $selected_ids );
455    }
456
457    /**
458     * Recursively render one branch of a term tree.
459     *
460     * @param array<int,\WP_Term[]> $by_parent    Terms grouped by parent ID.
461     * @param int                   $parent_id    Parent term ID (0 for top level).
462     * @param string                $name         Checkbox `name` attribute.
463     * @param int[]                 $selected_ids Selected term IDs.
464     */
465    private static function render_term_branch( array $by_parent, int $parent_id, string $name, array $selected_ids ): void {
466        if ( empty( $by_parent[ $parent_id ] ) ) {
467            return;
468        }
469        ?>
470        <ul class="beyondwords-setting__preselect--term-list" style="margin: 0; padding: 0; list-style: none;">
471            <?php foreach ( $by_parent[ $parent_id ] as $term ) : ?>
472                <li class="beyondwords-setting__preselect--term" style="margin: 0.5rem 0 0 1.5rem;">
473                    <label>
474                        <input
475                            type="checkbox"
476                            name="<?php echo esc_attr( $name ); ?>"
477                            value="<?php echo esc_attr( (string) $term->term_id ); ?>"
478                            <?php checked( in_array( (int) $term->term_id, $selected_ids, true ) ); ?>
479                        />
480                        <?php echo esc_html( $term->name ); ?>
481                    </label>
482                    <?php self::render_term_branch( $by_parent, (int) $term->term_id, $name, $selected_ids ); ?>
483                </li>
484            <?php endforeach; ?>
485        </ul>
486        <?php
487    }
488
489    /**
490     * Hierarchical taxonomy objects shown in the editor for a post type.
491     *
492     * @param string $post_type Post type slug.
493     *
494     * @return \WP_Taxonomy[]
495     */
496    private static function get_hierarchical_taxonomies( string $post_type ): array {
497        $taxonomies = get_object_taxonomies( $post_type, 'objects' );
498
499        return array_values(
500            array_filter(
501                $taxonomies,
502                static function ( $taxonomy ) {
503                    return $taxonomy->hierarchical && $taxonomy->show_ui;
504                }
505            )
506        );
507    }
508
509    /**
510     * Names of the hierarchical taxonomies for a post type.
511     *
512     * @param string $post_type Post type slug.
513     *
514     * @return string[]
515     */
516    private static function get_hierarchical_taxonomy_names( string $post_type ): array {
517        return array_values( wp_list_pluck( self::get_hierarchical_taxonomies( $post_type ), 'name' ) );
518    }
519}