PluginProbe
WebberZone Top 10 — Popular Posts / 4.5.2
WebberZone Top 10 — Popular Posts v4.5.2
4.5.2 4.5.1 4.5.0 4.4.3 4.4.2 4.4.1 4.4.0 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 trunk 1.0 1.0.1 1.1 1.2 1.3 1.4 1.4.1 1.5 1.5.1 1.5.2 1.5.3 1.6 All 118 releases
top-10 / includes / admin / settings / class-settings-api.php

class-settings-api.php in WebberZone Top 10 — Popular Posts 4.5.2, at includes/admin/settings/class-settings-api.php

1,343 lines 40.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Settings API.
4 *
5 * Functions to register, read, write and update settings.
6 * Portions of this code have been inspired by Easy Digital Downloads, WordPress Settings Sandbox, WordPress Settings API class, etc.
7 *
8 * @package WebberZone\Top_Ten
9 */
10
11 namespace WebberZone\Top_Ten\Admin\Settings;
12
13 // If this file is called directly, abort.
14 if ( ! defined( 'WPINC' ) ) {
15 die;
16 }
17
18 /**
19 * Settings API wrapper class
20 *
21 * @version 3.0.0
22 * @since 4.0.0
23 */
24 class Settings_API {
25
26 /**
27 * Current version number
28 *
29 * @var string
30 */
31 public const VERSION = '3.0.0';
32
33 /**
34 * Settings Key.
35 *
36 * @var string Settings Key.
37 */
38 public $settings_key;
39
40 /**
41 * Prefix which is used for creating the unique filters and actions.
42 *
43 * @var string Prefix.
44 */
45 public $prefix;
46
47 /**
48 * Translation strings.
49 *
50 * @see set_translation_strings()
51 *
52 * @var array Translation strings.
53 */
54 public $translation_strings;
55
56 /**
57 * Menus.
58 *
59 * @var array Menus.
60 */
61 public $menus = array();
62
63 /**
64 * Menu pages.
65 *
66 * @var array Menu pages.
67 */
68 public $menu_pages = array();
69
70 /**
71 * Default navigation tab.
72 *
73 * @var string Default navigation tab.
74 */
75 protected $default_tab;
76
77 /**
78 * Version used for cache-busting enqueued assets. Pass the plugin's own
79 * version via props so every plugin release refreshes browser caches.
80 *
81 * @var string
82 */
83 protected $version = self::VERSION;
84
85 /**
86 * Settings page.
87 *
88 * @var string Settings page.
89 */
90 public $settings_page = '';
91
92 /**
93 * Admin Footer Text. Displayed at the bottom of the plugin settings page.
94 *
95 * @var string Admin Footer Text.
96 */
97 protected $admin_footer_text;
98
99 /**
100 * Array containing the settings' sections.
101 *
102 * @var array Settings sections array.
103 */
104 protected $settings_sections = array();
105
106 /**
107 * Array containing the settings' fields.
108 *
109 * @var array Settings fields array.
110 */
111 protected $registered_settings = array();
112
113 /**
114 * Array containing the settings' fields that need to be upgraded to the current Settings API.
115 *
116 * @var array Settings fields array.
117 */
118 protected $upgraded_settings = array();
119
120 /**
121 * Help sidebar content.
122 *
123 * @var string Admin Footer Text.
124 */
125 protected $help_sidebar;
126
127 /**
128 * Array of help tabs.
129 *
130 * @var array Settings sections array.
131 */
132 protected $help_tabs = array();
133
134 /**
135 * Settings form.
136 *
137 * @var object Settings form.
138 */
139 public $settings_form;
140
141 /**
142 * Main constructor class.
143 *
144 * @param string $settings_key Settings key.
145 * @param string $prefix Prefix. Used for actions and filters.
146 * @param mixed $args {
147 * Array or string of arguments. Default is blank array.
148 * @type array $translation_strings Translation strings.
149 * @type array $settings_sections Settings sections.
150 * @type array $props Properties.
151 * @type array $registered_settings Registered settings.
152 * @type array $upgraded_settings Upgraded settings.
153 * }
154 */
155 public function __construct( $settings_key, $prefix, $args ) {
156
157 if ( ! defined( 'WZ_SETTINGS_API_VERSION' ) ) {
158 define( 'WZ_SETTINGS_API_VERSION', self::VERSION ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound
159 }
160
161 $this->settings_key = $settings_key;
162 $this->prefix = $prefix;
163
164 $defaults = array(
165 'translation_strings' => array(),
166 'props' => array(),
167 'settings_sections' => array(),
168 'registered_settings' => array(),
169 'upgraded_settings' => array(),
170 );
171 $args = wp_parse_args( $args, $defaults );
172
173 $this->hooks();
174 $this->set_translation_strings( $args['translation_strings'] );
175 $this->set_props( $args['props'] );
176 $this->set_sections( $args['settings_sections'] );
177 $this->set_registered_settings( $args['registered_settings'] );
178 $this->set_upgraded_settings( $args['upgraded_settings'] );
179 }
180
181 /**
182 * Adds the functions to the appropriate WordPress hooks.
183 */
184 public function hooks() {
185 add_action( 'admin_menu', array( $this, 'admin_menu' ), 11 );
186 add_action( 'admin_init', array( $this, 'admin_init' ) );
187 add_filter( 'admin_footer_text', array( $this, 'admin_footer_text' ) );
188 add_action( 'admin_enqueue_scripts', array( $this, 'admin_enqueue_scripts' ) );
189 add_filter( 'admin_body_class', array( $this, 'admin_body_class' ) );
190 }
191
192 /**
193 * Filters the CSS classes for the body tag in the admin.
194 *
195 * @param string $classes Space-separated list of CSS classes.
196 * @return string Space-separated list of CSS classes.
197 */
198 public function admin_body_class( $classes ) {
199 $current_screen = get_current_screen();
200
201 if ( in_array( $current_screen->id, $this->menu_pages, true ) ) {
202 $classes .= " {$this->prefix}-dashboard-page";
203 }
204 return $classes;
205 }
206
207 /**
208 * Sets properties.
209 *
210 * @param array|string $args {
211 * Array or string of arguments. Default is blank array.
212 *
213 * @type array $menus Array of admin menus. See add_custom_menu_page() for more info.
214 * @type string $default_tab Default tab.
215 * @type string $admin_footer_text Admin footer text.
216 * @type string $help_sidebar Help sidebar.
217 * @type array $help_tabs Help tabs.
218 * }
219 */
220 public function set_props( $args ) {
221
222 $defaults = array(
223 'menus' => array(),
224 'default_tab' => 'general',
225 'admin_footer_text' => '',
226 'help_sidebar' => '',
227 'help_tabs' => array(),
228 'version' => self::VERSION,
229 );
230
231 $args = wp_parse_args( $args, $defaults );
232
233 foreach ( $args as $name => $value ) {
234 $this->$name = $value;
235 }
236 }
237
238 /**
239 * Sets translation strings.
240 *
241 * @param array $strings {
242 * Array of translation strings.
243 *
244 * @type string $page_title Page title.
245 * @type string $menu_title Menu title.
246 * @type string $page_header Page header.
247 * @type string $reset_message Reset message.
248 * @type string $success_message Success message.
249 * @type string $save_changes Save changes button label.
250 * @type string $reset_settings Reset settings button label.
251 * @type string $reset_button_confirm Reset button confirmation message.
252 * }
253 *
254 * @return void
255 */
256 public function set_translation_strings( $strings ) {
257
258 // Args prefixed with an underscore are reserved for internal use.
259 $defaults = array(
260 'page_header' => '',
261 'reset_message' => 'Settings have been reset to their default values. Reload this page to view the updated settings.',
262 'success_message' => 'Settings updated.',
263 'save_changes' => 'Save Changes',
264 'reset_settings' => 'Reset all settings',
265 'reset_button_confirm' => 'Do you really want to reset all these settings to their default values?',
266 'button_label' => 'Choose File',
267 'previous_saved' => 'Previously saved',
268 'repeater_new_item' => 'New Item',
269 'repeater_move_up' => 'Move item up',
270 'repeater_move_down' => 'Move item down',
271 'repeater_remove_item' => 'Remove item',
272 'required_label' => 'Required',
273 'tom_select_no_results' => 'No results found for "%s"',
274 'search_placeholder' => 'Search settings',
275 'search_no_results' => 'No settings found. Try a different search term.',
276 'search_clear' => 'Clear search',
277 'search_results_single' => '%d setting found.',
278 'search_results_plural' => '%d settings found.',
279 'search_matches_label' => 'matching settings',
280 );
281
282 $strings = wp_parse_args( $strings, $defaults );
283
284 $this->translation_strings = $strings;
285 }
286
287 /**
288 * Set settings sections
289 *
290 * @param array $sections Setting sections array in the format of: id => Title.
291 * @return object Class object.
292 */
293 public function set_sections( $sections ) {
294 $this->settings_sections = (array) $sections;
295
296 return $this;
297 }
298
299 /**
300 * Add a single section
301 *
302 * @param array $section New Section.
303 * @return object Object of the class instance.
304 */
305 public function add_section( $section ) {
306 $this->settings_sections[] = $section;
307
308 return $this;
309 }
310
311 /**
312 * Set the settings fields for registered settings.
313 *
314 * @param array $registered_settings {
315 * Array of settings in format id => attributes.
316 * @type string $section Section title.
317 * @type string $id Field ID.
318 * @type string $name Field name.
319 * @type string $desc Field description.
320 * @type string $type Field type.
321 * @type string $options Field default option(s).
322 * @type string $max Field max. Applicable for numbers.
323 * @type string $min Field min. Applicable for numbers.
324 * @type string $step Field step. Applicable for numbers.
325 * @type string $size Field size. Applicable for text and textarea.
326 * @type string $field_class CSS class.
327 * @type array $field_attributes HTML Attributes in the form of attribute => value.
328 * @type string $placeholder Placeholder. Applicable for text and textarea.
329 * @type string $sanitize_callback Sanitize callback.
330 * }
331 * }
332 * }
333 * @return object Object of the class instance.
334 */
335 public function set_registered_settings( $registered_settings ) {
336 $this->registered_settings = (array) $registered_settings;
337
338 return $this;
339 }
340
341 /**
342 * Set the settings fields for settings to upgrade.
343 *
344 * @param array $upgraded_settings Settings array.
345 * @return object Object of the class instance.
346 */
347 public function set_upgraded_settings( $upgraded_settings = array() ) {
348 $this->upgraded_settings = (array) $upgraded_settings;
349
350 return $this;
351 }
352
353 /**
354 * Add a menu page to the WordPress admin area.
355 *
356 * @param array $menu Array of settings for the menu page.
357 *
358 * @return string|false The resulting page’s hook_suffix, or false if the user does not have the capability required.
359 */
360 public function add_custom_menu_page( $menu ) {
361 $defaults = array(
362
363 // Modes: submenu, management, options, theme, plugins, users, dashboard, posts, media, links, pages, comments.
364 'type' => 'submenu',
365
366 // Submenu default settings.
367 'parent_slug' => 'options-general.php',
368 'page_title' => '',
369 'menu_title' => '',
370 'capability' => $this->get_capability_for_menu(),
371 'menu_slug' => '',
372 'function' => array( $this, 'plugin_settings' ),
373
374 // Menu default settings.
375 'icon_url' => 'dashicons-admin-generic',
376 'position' => null,
377
378 );
379 $menu = wp_parse_args( $menu, $defaults );
380
381 $menu_page = false;
382
383 switch ( $menu['type'] ) {
384 case 'submenu':
385 $menu_page = add_submenu_page(
386 $menu['parent_slug'],
387 $menu['page_title'],
388 $menu['menu_title'],
389 $menu['capability'],
390 $menu['menu_slug'],
391 $menu['function'],
392 $menu['position']
393 );
394 break;
395 case 'management':
396 case 'options':
397 case 'theme':
398 case 'plugins':
399 case 'users':
400 case 'dashboard':
401 case 'posts':
402 case 'media':
403 case 'links':
404 case 'pages':
405 case 'comments':
406 $f = 'add_' . $menu['type'] . '_page';
407 if ( function_exists( $f ) ) {
408 $menu_page = $f(
409 $menu['page_title'],
410 $menu['menu_title'],
411 $menu['capability'],
412 $menu['menu_slug'],
413 $menu['function'],
414 $menu['position']
415 );
416 }
417 break;
418 default:
419 $menu_page = add_menu_page(
420 $menu['page_title'],
421 $menu['menu_title'],
422 $menu['capability'],
423 $menu['menu_slug'],
424 $menu['function'],
425 $menu['icon_url'],
426 $menu['position']
427 );
428 break;
429 }
430
431 return $menu_page;
432 }
433
434
435 /**
436 * Add admin menu.
437 */
438 public function admin_menu() {
439 foreach ( $this->menus as $menu ) {
440 $menu_page = $this->add_custom_menu_page( $menu );
441
442 $this->menu_pages[ $menu['menu_slug'] ] = $menu_page;
443 if ( isset( $menu['settings_page'] ) && $menu['settings_page'] ) {
444 $this->settings_page = $menu_page;
445 }
446 }
447
448 // Load the settings contextual help.
449 add_action( 'load-' . $this->settings_page, array( $this, 'settings_help' ) );
450 }
451
452 /**
453 * Get the appropriate capability for the menu based on the user's roles and settings.
454 *
455 * @param array $roles Array of roles to check.
456 * @param string $base_capability The default capability.
457 * @param \WP_User $current_user The current user object.
458 * @param array $role_capabilities Array of role capabilities.
459 * @return string The capability to use for the menu.
460 */
461 public static function get_capability_for_menu( $roles = array(), $base_capability = 'manage_options', $current_user = null, $role_capabilities = array() ) {
462 if ( ! $current_user ) {
463 $current_user = wp_get_current_user();
464 }
465
466 if ( empty( $roles ) || in_array( 'administrator', $current_user->roles, true ) ) {
467 return $base_capability;
468 }
469
470 if ( empty( $role_capabilities ) ) {
471 $role_capabilities = array(
472 'editor' => 'edit_others_posts',
473 'author' => 'publish_posts',
474 'contributor' => 'edit_posts',
475 'subscriber' => 'read',
476 );
477 }
478
479 foreach ( $current_user->roles as $role ) {
480 if ( in_array( $role, $roles, true ) && isset( $role_capabilities[ $role ] ) ) {
481 return $role_capabilities[ $role ];
482 }
483 }
484
485 return $base_capability;
486 }
487
488 /**
489 * Enqueue scripts and styles.
490 *
491 * @param string $hook The current admin page.
492 */
493 public function admin_enqueue_scripts( $hook ) {
494
495 $minimize = ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ? '' : '.min';
496
497 // Settings API scripts.
498 wp_register_script(
499 'wz-' . $this->prefix . '-admin',
500 plugins_url( 'js/settings-admin-scripts' . $minimize . '.js', __FILE__ ),
501 array( 'jquery', 'wp-color-picker', 'jquery-ui-tabs' ),
502 $this->version,
503 true
504 );
505 wp_register_script(
506 'wz-' . $this->prefix . '-codemirror',
507 plugins_url( 'js/apply-cm' . $minimize . '.js', __FILE__ ),
508 array( 'jquery', 'underscore', 'code-editor' ),
509 $this->version,
510 true
511 );
512 wp_register_script(
513 'wz-' . $this->prefix . '-media-selector',
514 plugins_url( 'js/media-selector' . $minimize . '.js', __FILE__ ),
515 array( 'jquery', 'media-editor', 'media-views' ),
516 $this->version,
517 true
518 );
519 wp_register_style(
520 'wz-' . $this->prefix . '-admin',
521 plugins_url( 'css/admin-style' . $minimize . '.css', __FILE__ ),
522 array( 'wp-color-picker' ),
523 $this->version
524 );
525
526 // Tom Select scripts and styles.
527 wp_register_style(
528 'wz-' . $this->prefix . '-tom-select',
529 plugins_url( 'css/tom-select.min.css', __FILE__ ),
530 array(),
531 $this->version
532 );
533 wp_register_script(
534 'wz-' . $this->prefix . '-tom-select',
535 plugins_url( 'js/tom-select.complete.min.js', __FILE__ ),
536 array( 'jquery' ),
537 $this->version,
538 true
539 );
540 wp_register_script(
541 'wz-' . $this->prefix . '-tom-select-init',
542 plugin_dir_url( __FILE__ ) . 'js/tom-select-init' . $minimize . '.js',
543 array( 'jquery', 'wz-' . $this->prefix . '-tom-select' ),
544 $this->version,
545 true
546 );
547 wp_localize_script(
548 "wz-{$this->prefix}-admin",
549 'WebberSettingsAdmin',
550 array(
551 'prefix' => $this->prefix,
552 'settings_key' => $this->settings_key,
553 'strings' => array(
554 'search_no_results' => esc_html( $this->translation_strings['search_no_results'] ?? 'No settings found. Try a different search term.' ),
555 'search_results_single' => esc_html( $this->translation_strings['search_results_single'] ?? '%d setting found.' ),
556 'search_results_plural' => esc_html( $this->translation_strings['search_results_plural'] ?? '%d settings found.' ),
557 'search_matches_label' => esc_html( $this->translation_strings['search_matches_label'] ?? 'matching settings' ),
558 ),
559 )
560 );
561
562 if ( $hook === $this->settings_page ) {
563 $args = array(
564 'strings' => array(
565 'no_results' => isset( $this->translation_strings['tom_select_no_results'] ) ? esc_html( $this->translation_strings['tom_select_no_results'] ) : 'No results found for "%s"',
566 ),
567 );
568 self::enqueue_scripts_styles( $this->prefix, $args );
569 }
570 }
571
572 /**
573 * Enqueues all scripts, styles, settings, and templates necessary to use the Settings API.
574 *
575 * @param string $prefix Prefix which is used for creating the unique filters and actions.
576 * @param array $args Array of arguments.
577 */
578 public static function enqueue_scripts_styles( $prefix, $args = array() ) {
579
580 wp_enqueue_media();
581
582 wp_enqueue_code_editor(
583 array(
584 'type' => 'text/html',
585 'codemirror' => array(
586 'indentUnit' => 2,
587 'tabSize' => 2,
588 ),
589 )
590 );
591
592 wp_enqueue_script( "wz-{$prefix}-admin" );
593 wp_enqueue_script( "wz-{$prefix}-codemirror" );
594 wp_enqueue_script( "wz-{$prefix}-media-selector" );
595
596 // Enqueue Tom Select.
597 wp_enqueue_style( "wz-{$prefix}-tom-select" );
598 wp_enqueue_script( "wz-{$prefix}-tom-select" );
599
600 $defaults = array(
601 'endpoint' => 'category',
602 'strings' => array(
603 'no_results' => 'No results found for "%s"',
604 ),
605 );
606
607 $args = wp_parse_args( $args, $defaults );
608
609 // Localize Tom Select settings.
610 wp_localize_script(
611 "wz-{$prefix}-tom-select-init",
612 'WebberTomSelectSettings',
613 $args
614 );
615 wp_enqueue_script( "wz-{$prefix}-tom-select-init" );
616
617 wp_enqueue_style( 'wz-' . $prefix . '-admin' );
618 }
619
620 /**
621 * Initialize and registers the settings sections and fields to WordPress
622 *
623 * Usually this should be called at `admin_init` hook.
624 *
625 * This public function gets the initiated settings sections and fields. Then
626 * registers them to WordPress and ready for use.
627 */
628 public function admin_init() {
629
630 $settings_key = $this->settings_key;
631
632 if ( false === get_option( $settings_key ) ) {
633 add_option( $settings_key, $this->settings_defaults() );
634 }
635
636 $this->settings_form = new Settings_Form(
637 array(
638 'settings_key' => $settings_key,
639 'prefix' => $this->prefix,
640 'translation_strings' => $this->translation_strings,
641 )
642 );
643
644 foreach ( $this->registered_settings as $section => $settings ) {
645
646 add_settings_section(
647 "{$settings_key}_{$section}", // ID used to identify this section and with which to register options.
648 '', // No title, we will handle this via a separate function.
649 '__return_false', // No callback function needed. We'll process this separately.
650 "{$settings_key}_{$section}" // Page on which these options will be added.
651 );
652
653 foreach ( $settings as $setting ) {
654
655 $args = self::parse_field_args( $setting, $section );
656
657 $id = $args['id'];
658 $name = $args['name'];
659 $type = isset( $args['type'] ) ? $args['type'] : 'text';
660 $callback = method_exists( $this->settings_form, "callback_{$type}" ) ? array( $this->settings_form, "callback_{$type}" ) : array( $this->settings_form, 'callback_missing' );
661 $title = $name;
662
663 if ( ! in_array( $type, array( 'header', 'repeater' ), true ) ) {
664 $title = sprintf(
665 '<label for="%1$s">%2$s</label>',
666 esc_attr( $this->settings_form->get_field_id( $args ) ),
667 wp_kses_post( $name )
668 );
669 }
670
671 // Tag header rows so the settings search can group fields under them.
672 if ( 'header' === $type ) {
673 $args['class'] = trim( ( $args['class'] ?? '' ) . ' wz-settings-header-row', " \t\n\r\0\x0B" );
674 }
675
676 add_settings_field(
677 "{$settings_key}[{$id}]", // ID of the settings field. We save it within the settings array.
678 $title, // Label of the setting.
679 $callback, // Function to handle the setting.
680 "{$settings_key}_{$section}", // Page to display the setting. In our case it is the section as defined above.
681 "{$settings_key}_{$section}", // Name of the section.
682 $args
683 );
684 }
685 }
686
687 // Register the settings into the options table.
688 register_setting(
689 $settings_key,
690 $settings_key,
691 array(
692 'type' => 'object',
693 'default' => $this->settings_defaults(),
694 'sanitize_callback' => array( $this, 'settings_sanitize' ),
695 // The value is an open-ended map with no REST schema, and settings_sanitize() expects a form submission.
696 'show_in_rest' => false,
697 )
698 );
699 }
700
701 /**
702 * Flattens $this->registered_settings into $setting[id] => $setting[type] format.
703 *
704 * @return array Default settings
705 */
706 public function get_registered_settings_types() {
707
708 $options = array();
709
710 // Populate some default values.
711 foreach ( $this->registered_settings as $tab => $settings ) {
712 foreach ( $settings as $option ) {
713 $options[ $option['id'] ] = $option['type'];
714 }
715 }
716
717 /**
718 * Filters the settings array.
719 *
720 * @param array $options Default settings.
721 */
722 return apply_filters( $this->prefix . '_get_settings_types', $options ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
723 }
724
725
726 /**
727 * Default settings.
728 *
729 * @return array Default settings
730 */
731 public function settings_defaults() {
732
733 $options = array();
734
735 // Populate some default values.
736 foreach ( $this->registered_settings as $tab => $settings ) {
737 foreach ( $settings as $option ) {
738 /**
739 * Skip settings that are not really settings.
740 *
741 * @param array $non_setting_types Array of types which are not settings.
742 */
743 $non_setting_types = apply_filters( $this->prefix . '_non_setting_types', array( 'header', 'descriptive_text' ) ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
744
745 if ( in_array( $option['type'], $non_setting_types, true ) ) {
746 continue;
747 }
748
749 // Base default per type.
750 $options[ $option['id'] ] = ( 'checkbox' === $option['type'] ) ? 0 : '';
751
752 // Prefer the explicit 'default' key when provided.
753 if ( isset( $option['default'] ) ) {
754 $options[ $option['id'] ] = $option['default'];
755 } else {
756 // Back-compat for legacy configs that used 'options' to store default values for text-like fields.
757 if ( in_array( $option['type'], array( 'textarea', 'css', 'html', 'text', 'url', 'csv', 'color', 'numbercsv', 'postids', 'posttypes', 'number', 'wysiwyg', 'file', 'password' ), true ) && isset( $option['options'] ) ) {
758 $options[ $option['id'] ] = $option['options'];
759 }
760
761 // Back-compat: when checkbox used 'options' truthy to indicate checked by default.
762 if ( 'checkbox' === $option['type'] && ! empty( $option['options'] ) ) {
763 $options[ $option['id'] ] = 1;
764 }
765 }
766 }
767 }
768
769 $options = array_merge( $options, $this->upgraded_settings );
770
771 /**
772 * Filters the default settings array.
773 *
774 * @param array $options Default settings.
775 */
776 return apply_filters( $this->prefix . '_settings_defaults', $options ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
777 }
778
779
780 /**
781 * Get the default option for a specific key
782 *
783 * @param string $key Key of the option to fetch.
784 * @return mixed
785 */
786 public function get_default_option( $key = '' ) {
787
788 $default_settings = $this->settings_defaults();
789
790 if ( array_key_exists( $key, $default_settings ) ) {
791 return $default_settings[ $key ];
792 } else {
793 return false;
794 }
795 }
796
797
798 /**
799 * Reset settings.
800 *
801 * @return void
802 */
803 public function settings_reset() {
804 delete_option( $this->settings_key );
805 }
806
807 /**
808 * Get sanitization callback for given Settings key.
809 *
810 * @param string $key Settings key.
811 *
812 * @return mixed Callback function or false if callback isn't found.
813 */
814 public function get_sanitize_callback( $key = '' ) {
815 if ( empty( $key ) ) {
816 return false;
817 }
818
819 $settings_sanitize = new Settings_Sanitize(
820 array(
821 'settings_key' => $this->settings_key,
822 'prefix' => $this->prefix,
823 )
824 );
825
826 // Iterate over registered fields and see if we can find proper callback.
827 foreach ( $this->registered_settings as $section => $settings ) {
828 foreach ( $settings as $setting ) {
829 if ( $setting['id'] !== $key ) {
830 continue;
831 }
832
833 if ( isset( $setting['sanitize_callback'] ) && is_callable( $setting['sanitize_callback'] ) ) {
834 return $setting['sanitize_callback'];
835 }
836
837 $method = 'sanitize_' . $setting['type'] . '_field';
838
839 // Field types with no callback of their own must still not store raw input.
840 if ( ! is_callable( array( $settings_sanitize, $method ) ) ) {
841 $method = 'sanitize_missing';
842 }
843
844 // Every callback receives the field configuration so choice fields can validate against their own options.
845 return function ( $value ) use ( $settings_sanitize, $method, $setting ) {
846 return $settings_sanitize->$method( $value, $setting );
847 };
848 }
849 }
850
851 return false;
852 }
853
854 /**
855 * Get the settings keys that are rendered locked (disabled or pro-gated).
856 *
857 * @return array Map of settings key => true for each locked setting.
858 */
859 public function get_locked_settings() {
860 $locked = array();
861
862 foreach ( $this->registered_settings as $settings ) {
863 foreach ( $settings as $setting ) {
864 if ( isset( $setting['id'] ) && ( ! empty( $setting['disabled'] ) || ! empty( $setting['pro'] ) ) ) {
865 $locked[ $setting['id'] ] = true;
866 }
867 }
868 }
869
870 return $locked;
871 }
872
873 /**
874 * Sanitize the form data being submitted.
875 *
876 * @param mixed $input Unsanitized input. An array for form submissions, but REST and WP-CLI may pass anything.
877 * @return array Sanitized array
878 */
879 public function settings_sanitize( $input ) {
880 // Set when a classic form is submitted; used only to pick the active tab below.
881 $referrer = array();
882
883 if ( ! empty( $_POST['_wp_http_referer'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
884 parse_str( sanitize_text_field( wp_unslash( $_POST['_wp_http_referer'] ) ), $referrer ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
885 }
886
887 // Check if we need to set to defaults.
888 $reset = isset( $_POST['settings_reset'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
889
890 if ( $reset ) {
891 $this->settings_reset();
892 $settings = get_option( $this->settings_key );
893
894 add_settings_error( $this->prefix . '-notices', '', $this->translation_strings['reset_message'], 'error' );
895
896 return $settings;
897 }
898
899 // Get the various settings we've registered.
900 $settings = get_option( $this->settings_key );
901 $settings = is_array( $settings ) ? $settings : array();
902 $settings_types = $this->get_registered_settings_types();
903 $locked = $this->get_locked_settings();
904
905 // Get the tab. This is also our settings' section.
906 $tab = $referrer['tab'] ?? $this->default_tab;
907
908 $input = is_array( $input ) ? $input : array();
909
910 /**
911 * Filter the settings for the tab. e.g. prefix_settings_general_sanitize.
912 *
913 * @param array $input Input unclean array
914 */
915 $input = apply_filters( $this->prefix . '_settings_' . $tab . '_sanitize', $input ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
916
917 // Start from what is stored. Submitted values are merged back in below, once sanitized.
918 $output = $settings;
919
920 // Loop through each setting being saved and pass it through a sanitization filter.
921 foreach ( $settings_types as $key => $type ) {
922 /**
923 * Skip settings that are not really settings.
924 *
925 * @param array $non_setting_types Array of types which are not settings.
926 */
927 $non_setting_types = apply_filters( $this->prefix . '_non_setting_types', array( 'header', 'descriptive_text' ) ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
928
929 if ( in_array( $type, $non_setting_types, true ) ) {
930 continue;
931 }
932
933 if ( array_key_exists( $key, $input ) ) {
934 $sanitize_callback = $this->get_sanitize_callback( $key );
935
936 // If callback is set, call it.
937 if ( $sanitize_callback ) {
938 $output[ $key ] = call_user_func( $sanitize_callback, $input[ $key ] );
939 continue;
940 }
941 }
942
943 // Delete any key that is not present when we submit the input array.
944 if ( ! isset( $input[ $key ] ) ) {
945 // Disabled fields are never submitted, so a missing key must not delete them.
946 if ( ! isset( $locked[ $key ] ) ) {
947 unset( $output[ $key ] );
948 }
949 }
950 }
951
952 // Keys added by the tab filter are not registered settings, but must not be stored raw either.
953 $settings_sanitize = new Settings_Sanitize(
954 array(
955 'settings_key' => $this->settings_key,
956 'prefix' => $this->prefix,
957 )
958 );
959
960 foreach ( array_diff_key( $input, $settings_types ) as $key => $value ) {
961 $output[ sanitize_text_field( (string) $key ) ] = $settings_sanitize->sanitize_missing( $value );
962 }
963
964 add_settings_error( $this->prefix . '-notices', '', $this->translation_strings['success_message'], 'updated' );
965
966 /**
967 * Filter the settings array before it is returned.
968 *
969 * @param array $output Settings array.
970 * @param array $input Input settings array.
971 */
972 return apply_filters( $this->prefix . '_settings_sanitize', $output, $input ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
973 }
974
975 /**
976 * Render the settings page.
977 */
978 public function plugin_settings() {
979 ?>
980 <div class="wrap">
981 <?php do_action( $this->prefix . '_settings_page_header_before' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound ?>
982 <h1><?php echo esc_html( $this->translation_strings['page_header'] ); ?></h1>
983 <?php do_action( $this->prefix . '_settings_page_header' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound ?>
984
985 <?php
986 // WordPress automatically calls settings_errors() on Settings pages.
987 // Only call it manually on custom menu pages to prevent duplicates.
988 $current_screen = get_current_screen();
989 if ( $current_screen && 0 !== strpos( $current_screen->base, 'settings_page_' ) ) {
990 settings_errors( $this->prefix . '-notices' );
991 }
992 ?>
993
994 <div id="poststuff">
995 <div id="post-body" class="metabox-holder columns-2 wz-settings-post-body">
996 <div id="post-body-content" class="wz-vertical-tabs">
997
998 <?php $this->show_navigation(); ?>
999 <?php $this->show_form(); ?>
1000
1001 </div><!-- /#post-body-content -->
1002
1003 <div id="postbox-container-1" class="postbox-container">
1004
1005 <div id="side-sortables" class="meta-box-sortables ui-sortable">
1006 <?php
1007 $sidebar_file = dirname( __DIR__ ) . '/sidebar.php';
1008 if ( file_exists( $sidebar_file ) ) {
1009 include_once $sidebar_file;
1010 }
1011 ?>
1012 </div><!-- /#side-sortables -->
1013
1014 </div><!-- /#postbox-container-1 -->
1015 </div><!-- /#post-body -->
1016 <br class="clear" />
1017 </div><!-- /#poststuff -->
1018
1019 </div><!-- /.wrap -->
1020
1021 <?php
1022 }
1023
1024 /**
1025 * Show navigations as tab
1026 *
1027 * Shows all the settings section labels as tab
1028 */
1029 public function show_navigation() {
1030 $active_tab = isset( $_GET['tab'] ) && array_key_exists( sanitize_key( wp_unslash( $_GET['tab'] ) ), $this->settings_sections ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : $this->default_tab; // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended
1031
1032 $count = count( $this->settings_sections );
1033
1034 // Don't show the navigation if only one section exists.
1035 if ( 1 === $count ) {
1036 return;
1037 }
1038
1039 $html = '<ul class="nav-tab-wrapper">';
1040
1041 // Settings search box. Rendered via wp_kses() with an extended allowed list below as
1042 // wp_kses_post() strips input and button tags.
1043 $search_box = sprintf(
1044 '<li class="wz-settings-search-wrap">' .
1045 '<label class="screen-reader-text" for="%1$s">%2$s</label>' .
1046 '<span class="wz-settings-search-box">' .
1047 '<input type="search" id="%1$s" class="wz-settings-search" placeholder="%2$s" autocomplete="off" />' .
1048 '<button type="button" class="wz-settings-search-clear" aria-label="%3$s" hidden><span aria-hidden="true">&times;</span></button>' .
1049 '</span>' .
1050 '<span class="wz-settings-search-status screen-reader-text" role="status" aria-live="polite"></span>' .
1051 '</li>',
1052 esc_attr( "{$this->prefix}-settings-search" ),
1053 esc_attr( $this->translation_strings['search_placeholder'] ?? 'Search settings' ),
1054 esc_attr( $this->translation_strings['search_clear'] ?? 'Clear search' )
1055 );
1056
1057 foreach ( $this->settings_sections as $tab_id => $tab_name ) {
1058
1059 $active = $active_tab === $tab_id ? ' ' : '';
1060
1061 $html .= sprintf(
1062 '<li><a href="#%s" title="%s" class="nav-tab %s">%s</a></li>',
1063 esc_attr( $tab_id ),
1064 esc_attr( $tab_name ),
1065 sanitize_html_class( $active ),
1066 esc_html( $tab_name )
1067 );
1068
1069 }
1070
1071 $html .= '</ul>';
1072
1073 $allowed_html = wp_kses_allowed_html( 'post' );
1074 $allowed_html['input'] = array(
1075 'type' => true,
1076 'id' => true,
1077 'class' => true,
1078 'placeholder' => true,
1079 'autocomplete' => true,
1080 );
1081 $allowed_html['button'] = array(
1082 'type' => true,
1083 'class' => true,
1084 'aria-label' => true,
1085 'hidden' => true,
1086 );
1087 $allowed_html['span'] = array_merge(
1088 (array) ( $allowed_html['span'] ?? array() ),
1089 array(
1090 'class' => true,
1091 'role' => true,
1092 'aria-live' => true,
1093 'aria-hidden' => true,
1094 )
1095 );
1096
1097 $html = str_replace( '<ul class="nav-tab-wrapper">', '<ul class="nav-tab-wrapper">' . $search_box, $html );
1098
1099 echo wp_kses( $html, $allowed_html );
1100 }
1101
1102 /**
1103 * Show the section settings forms
1104 *
1105 * This public function displays every sections in a different form
1106 */
1107 public function show_form() {
1108 ?>
1109
1110 <form method="post" action="options.php" id="<?php echo esc_attr( "{$this->prefix}-settings-form" ); ?>">
1111
1112 <?php settings_fields( $this->settings_key ); ?>
1113
1114 <?php foreach ( $this->settings_sections as $tab_id => $tab_name ) : ?>
1115
1116 <div id="<?php echo esc_attr( $tab_id ); ?>">
1117 <h2 class="wz-section-title" tabindex="-1"><?php echo esc_html( $tab_name ); ?></h2>
1118 <table class="form-table">
1119 <?php
1120 do_settings_fields( $this->prefix . '_settings_' . $tab_id, $this->prefix . '_settings_' . $tab_id );
1121 ?>
1122 </table>
1123 <p>
1124 <?php
1125 // Default submit button.
1126 submit_button(
1127 $this->translation_strings['save_changes'],
1128 'primary',
1129 'submit',
1130 false
1131 );
1132
1133 echo '&nbsp;&nbsp;';
1134
1135 // Reset button.
1136 $confirm = esc_js( $this->translation_strings['reset_button_confirm'] );
1137 submit_button(
1138 $this->translation_strings['reset_settings'],
1139 'secondary',
1140 'settings_reset',
1141 false,
1142 array(
1143 'onclick' => "return confirm('{$confirm}');",
1144 )
1145 );
1146
1147 echo '&nbsp;&nbsp;';
1148
1149 /**
1150 * Action to add more buttons in each tab.
1151 *
1152 * @param string $tab_id Tab ID.
1153 * @param string $tab_name Tab name.
1154 * @param array $settings_sections Settings sections.
1155 */
1156 do_action( $this->prefix . '_settings_form_buttons', $tab_id, $tab_name, $this->settings_sections ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
1157 ?>
1158 </p>
1159 <p class="wz-modified-legend"><span class="wz-modified-dot"></span><?php echo esc_html( $this->translation_strings['modified_legend'] ?? 'Setting modified from its default value' ); ?></p>
1160 </div><!-- /#tab_id-->
1161
1162 <?php endforeach; ?>
1163
1164 </form>
1165
1166 <?php
1167 }
1168
1169 /**
1170 * Add rating links to the admin dashboard
1171 *
1172 * @param string $footer_text The existing footer text.
1173 * @return string Updated Footer text
1174 */
1175 public function admin_footer_text( $footer_text ) {
1176
1177 if ( ! empty( $this->admin_footer_text ) && get_current_screen()->id === $this->settings_page ) {
1178
1179 $text = $this->admin_footer_text;
1180
1181 return str_replace( '</span>', '', $footer_text ) . ' | ' . $text . '</span>';
1182 } else {
1183 return $footer_text;
1184 }
1185 }
1186
1187 /**
1188 * Function to add the contextual help in the settings page.
1189 */
1190 public function settings_help() {
1191 $screen = get_current_screen();
1192
1193 if ( $screen->id !== $this->settings_page ) {
1194 return;
1195 }
1196
1197 $screen->set_help_sidebar( $this->help_sidebar );
1198
1199 foreach ( $this->help_tabs as $tab ) {
1200 $screen->add_help_tab( $tab );
1201 }
1202 }
1203
1204 /**
1205 * Parse field arguments with defaults.
1206 *
1207 * @param array $field Field arguments.
1208 * @param string $section Section name.
1209 *
1210 * @return array Parsed field arguments.
1211 */
1212 public static function parse_field_args( $field, $section = '' ) {
1213 $defaults = array(
1214 'id' => null,
1215 'name' => '',
1216 'desc' => '',
1217 'type' => 'text',
1218 'size' => null,
1219 'options' => '',
1220 'default' => '',
1221 'min' => 0,
1222 'max' => 999999,
1223 'step' => 1,
1224 'field_class' => '',
1225 'field_attributes' => array(),
1226 'placeholder' => '',
1227 'readonly' => false,
1228 'required' => false,
1229 'disabled' => false,
1230 'pro' => false,
1231 'section' => $section,
1232 );
1233
1234 $field = wp_parse_args( $field, $defaults );
1235
1236 // Add required indicator to field name if the field is required.
1237 if ( ! empty( $field['required'] ) && true === $field['required'] ) {
1238 $field['name'] = sprintf( '%s <span class="required" title="%s">*</span>', $field['name'], 'Required' );
1239 }
1240
1241 return $field;
1242 }
1243
1244 /**
1245 * Get the encryption key for API key encryption/decryption.
1246 *
1247 * @param string $prefix Optional prefix for fallback key.
1248 * @return string The encryption key.
1249 */
1250 public static function get_encryption_key( $prefix = '' ) {
1251 $fallback = $prefix ? str_replace( '-', '_', $prefix ) . '_encryption_fallback' : 'settings_api_encryption_fallback';
1252 return defined( 'AUTH_SALT' ) ? AUTH_SALT : ( defined( 'SECURE_AUTH_SALT' ) ? SECURE_AUTH_SALT : hash( 'sha256', __NAMESPACE__ . $fallback ) );
1253 }
1254
1255 /**
1256 * Encrypts an API key using either OpenSSL or Sodium, if available.
1257 *
1258 * @param string $key The API key to encrypt.
1259 * @param string $prefix Optional prefix for fallback key.
1260 * @return string The encrypted API key, or the plain text key if no secure method is available.
1261 */
1262 public static function encrypt_api_key( $key, $prefix = '' ) {
1263 if ( empty( $key ) ) {
1264 return '';
1265 }
1266
1267 // Use OpenSSL if available.
1268 if ( extension_loaded( 'openssl' ) ) {
1269 $iv_length = openssl_cipher_iv_length( 'aes-256-cbc' );
1270 $iv = openssl_random_pseudo_bytes( $iv_length );
1271 $encrypted = openssl_encrypt( $key, 'aes-256-cbc', self::get_encryption_key( $prefix ), 0, $iv );
1272
1273 // Store IV + ciphertext in hex format.
1274 return 'enc:' . bin2hex( $iv . $encrypted );
1275 }
1276
1277 // Use Sodium (libsodium) if OpenSSL is unavailable.
1278 if ( extension_loaded( 'sodium' ) ) {
1279 $sodium_key = substr( hash( 'sha256', self::get_encryption_key( $prefix ), true ), 0, SODIUM_CRYPTO_SECRETBOX_KEYBYTES );
1280 $nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
1281 $encrypted = sodium_crypto_secretbox( $key, $nonce, $sodium_key );
1282
1283 return 'enc:' . sodium_bin2hex( $nonce . $encrypted );
1284 }
1285
1286 return $key;
1287 }
1288
1289 /**
1290 * Decrypts an API key using either OpenSSL or Sodium, if available.
1291 *
1292 * @param string $encrypted_key The encrypted API key to decrypt.
1293 * @param string $prefix Optional prefix for fallback key.
1294 * @return string The decrypted API key, or the encrypted key if no secure method is available.
1295 */
1296 public static function decrypt_api_key( $encrypted_key, $prefix = '' ) {
1297 if ( empty( $encrypted_key ) ) {
1298 return '';
1299 }
1300
1301 // If the key doesn't start with 'enc:', it's not encrypted.
1302 if ( strpos( $encrypted_key, 'enc:' ) !== 0 ) {
1303 return $encrypted_key;
1304 }
1305
1306 // Remove the 'enc:' prefix.
1307 $encrypted_key = substr( $encrypted_key, 4 );
1308
1309 // Try OpenSSL decryption.
1310 if ( extension_loaded( 'openssl' ) ) {
1311 $data = hex2bin( $encrypted_key );
1312 if ( false === $data ) {
1313 return '';
1314 }
1315
1316 $iv_length = openssl_cipher_iv_length( 'aes-256-cbc' );
1317 $iv = mb_substr( $data, 0, $iv_length, '8bit' );
1318 $ciphertext = mb_substr( $data, $iv_length, null, '8bit' );
1319
1320 $decrypted = openssl_decrypt( $ciphertext, 'aes-256-cbc', self::get_encryption_key( $prefix ), 0, $iv );
1321 return false === $decrypted ? '' : $decrypted;
1322 }
1323
1324 // Try Sodium (libsodium) decryption.
1325 if ( extension_loaded( 'sodium' ) ) {
1326 $sodium_key = substr( hash( 'sha256', self::get_encryption_key( $prefix ), true ), 0, SODIUM_CRYPTO_SECRETBOX_KEYBYTES );
1327 $decoded = sodium_hex2bin( $encrypted_key );
1328
1329 if ( ! $decoded ) {
1330 return '';
1331 }
1332
1333 $nonce = mb_substr( $decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, '8bit' );
1334 $ciphertext = mb_substr( $decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, null, '8bit' );
1335 $decrypted = sodium_crypto_secretbox_open( $ciphertext, $nonce, $sodium_key );
1336
1337 return false === $decrypted ? '' : $decrypted;
1338 }
1339
1340 return $encrypted_key;
1341 }
1342 }
1343