PluginProbe
BeyondWords – AI audio for publishers / trunk
BeyondWords – AI audio for publishers vtrunk
7.1.0 trunk 4.0.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.1.0 4.1.1 4.1.2 4.2.0 4.2.1 4.2.2 4.2.3 4.2.4 4.3.0 4.4.0 4.5.0 4.5.1 4.6.0 4.6.1 4.6.2 4.7.0 All 43 releases
speechkit / src / settings / class-preselect.php

class-preselect.php in BeyondWords – AI audio for publishers trunk, at src/settings/class-preselect.php

520 lines 14.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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
11 declare( strict_types = 1 );
12
13 namespace BeyondWords\Settings;
14
15 defined( '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 */
25 class 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 }
520