PluginProbe
BeyondWords – AI audio for publishers / 7.0.0
BeyondWords – AI audio for publishers v7.0.0
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 / editor / components / settings-fields / class-settings-fields.php

class-settings-fields.php in BeyondWords – AI audio for publishers 7.0.0, at src/editor/components/settings-fields/class-settings-fields.php

675 lines 17.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare( 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
16 namespace BeyondWords\Editor\Components;
17
18 /**
19 * SettingsFields
20 *
21 * @since 7.0.0
22 */
23 defined( 'ABSPATH' ) || exit;
24
25 class 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 }
675