PluginProbe
WebberZone Top 10 — Popular Posts / 4.4.1
WebberZone Top 10 — Popular Posts v4.4.1
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 1.6.1 All 117 releases
top-10 / includes / admin / settings / class-settings-api.php

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

1,307 lines 38.7 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 2.9.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 = '2.11.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 'required_label' => 'Required',
270 'tom_select_no_results' => 'No results found for "%s"',
271 'search_placeholder' => 'Search settings',
272 'search_no_results' => 'No settings found. Try a different search term.',
273 'search_clear' => 'Clear search',
274 'search_results_single' => '%d setting found.',
275 'search_results_plural' => '%d settings found.',
276 'search_matches_label' => 'matching settings',
277 );
278
279 $strings = wp_parse_args( $strings, $defaults );
280
281 $this->translation_strings = $strings;
282 }
283
284 /**
285 * Set settings sections
286 *
287 * @param array $sections Setting sections array in the format of: id => Title.
288 * @return object Class object.
289 */
290 public function set_sections( $sections ) {
291 $this->settings_sections = (array) $sections;
292
293 return $this;
294 }
295
296 /**
297 * Add a single section
298 *
299 * @param array $section New Section.
300 * @return object Object of the class instance.
301 */
302 public function add_section( $section ) {
303 $this->settings_sections[] = $section;
304
305 return $this;
306 }
307
308 /**
309 * Set the settings fields for registered settings.
310 *
311 * @param array $registered_settings {
312 * Array of settings in format id => attributes.
313 * @type string $section Section title.
314 * @type string $id Field ID.
315 * @type string $name Field name.
316 * @type string $desc Field description.
317 * @type string $type Field type.
318 * @type string $options Field default option(s).
319 * @type string $max Field max. Applicable for numbers.
320 * @type string $min Field min. Applicable for numbers.
321 * @type string $step Field step. Applicable for numbers.
322 * @type string $size Field size. Applicable for text and textarea.
323 * @type string $field_class CSS class.
324 * @type array $field_attributes HTML Attributes in the form of attribute => value.
325 * @type string $placeholder Placeholder. Applicable for text and textarea.
326 * @type string $sanitize_callback Sanitize callback.
327 * }
328 * }
329 * }
330 * @return object Object of the class instance.
331 */
332 public function set_registered_settings( $registered_settings ) {
333 $this->registered_settings = (array) $registered_settings;
334
335 return $this;
336 }
337
338 /**
339 * Set the settings fields for settings to upgrade.
340 *
341 * @param array $upgraded_settings Settings array.
342 * @return object Object of the class instance.
343 */
344 public function set_upgraded_settings( $upgraded_settings = array() ) {
345 $this->upgraded_settings = (array) $upgraded_settings;
346
347 return $this;
348 }
349
350 /**
351 * Add a menu page to the WordPress admin area.
352 *
353 * @param array $menu Array of settings for the menu page.
354 *
355 * @return string|false The resulting page’s hook_suffix, or false if the user does not have the capability required.
356 */
357 public function add_custom_menu_page( $menu ) {
358 $defaults = array(
359
360 // Modes: submenu, management, options, theme, plugins, users, dashboard, posts, media, links, pages, comments.
361 'type' => 'submenu',
362
363 // Submenu default settings.
364 'parent_slug' => 'options-general.php',
365 'page_title' => '',
366 'menu_title' => '',
367 'capability' => $this->get_capability_for_menu(),
368 'menu_slug' => '',
369 'function' => array( $this, 'plugin_settings' ),
370
371 // Menu default settings.
372 'icon_url' => 'dashicons-admin-generic',
373 'position' => null,
374
375 );
376 $menu = wp_parse_args( $menu, $defaults );
377
378 $menu_page = false;
379
380 switch ( $menu['type'] ) {
381 case 'submenu':
382 $menu_page = add_submenu_page(
383 $menu['parent_slug'],
384 $menu['page_title'],
385 $menu['menu_title'],
386 $menu['capability'],
387 $menu['menu_slug'],
388 $menu['function'],
389 $menu['position']
390 );
391 break;
392 case 'management':
393 case 'options':
394 case 'theme':
395 case 'plugins':
396 case 'users':
397 case 'dashboard':
398 case 'posts':
399 case 'media':
400 case 'links':
401 case 'pages':
402 case 'comments':
403 $f = 'add_' . $menu['type'] . '_page';
404 if ( function_exists( $f ) ) {
405 $menu_page = $f(
406 $menu['page_title'],
407 $menu['menu_title'],
408 $menu['capability'],
409 $menu['menu_slug'],
410 $menu['function'],
411 $menu['position']
412 );
413 }
414 break;
415 default:
416 $menu_page = add_menu_page(
417 $menu['page_title'],
418 $menu['menu_title'],
419 $menu['capability'],
420 $menu['menu_slug'],
421 $menu['function'],
422 $menu['icon_url'],
423 $menu['position']
424 );
425 break;
426 }
427
428 return $menu_page;
429 }
430
431
432 /**
433 * Add admin menu.
434 */
435 public function admin_menu() {
436 foreach ( $this->menus as $menu ) {
437 $menu_page = $this->add_custom_menu_page( $menu );
438
439 $this->menu_pages[ $menu['menu_slug'] ] = $menu_page;
440 if ( isset( $menu['settings_page'] ) && $menu['settings_page'] ) {
441 $this->settings_page = $menu_page;
442 }
443 }
444
445 // Load the settings contextual help.
446 add_action( 'load-' . $this->settings_page, array( $this, 'settings_help' ) );
447 }
448
449 /**
450 * Get the appropriate capability for the menu based on the user's roles and settings.
451 *
452 * @param array $roles Array of roles to check.
453 * @param string $base_capability The default capability.
454 * @param \WP_User $current_user The current user object.
455 * @param array $role_capabilities Array of role capabilities.
456 * @return string The capability to use for the menu.
457 */
458 public static function get_capability_for_menu( $roles = array(), $base_capability = 'manage_options', $current_user = null, $role_capabilities = array() ) {
459 if ( ! $current_user ) {
460 $current_user = wp_get_current_user();
461 }
462
463 if ( empty( $roles ) || in_array( 'administrator', $current_user->roles, true ) ) {
464 return $base_capability;
465 }
466
467 if ( empty( $role_capabilities ) ) {
468 $role_capabilities = array(
469 'editor' => 'edit_others_posts',
470 'author' => 'publish_posts',
471 'contributor' => 'edit_posts',
472 'subscriber' => 'read',
473 );
474 }
475
476 foreach ( $current_user->roles as $role ) {
477 if ( in_array( $role, $roles, true ) && isset( $role_capabilities[ $role ] ) ) {
478 return $role_capabilities[ $role ];
479 }
480 }
481
482 return $base_capability;
483 }
484
485 /**
486 * Enqueue scripts and styles.
487 *
488 * @param string $hook The current admin page.
489 */
490 public function admin_enqueue_scripts( $hook ) {
491
492 $minimize = ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ? '' : '.min';
493
494 // Settings API scripts.
495 wp_register_script(
496 'wz-' . $this->prefix . '-admin',
497 plugins_url( 'js/settings-admin-scripts' . $minimize . '.js', __FILE__ ),
498 array( 'jquery', 'wp-color-picker', 'jquery-ui-tabs' ),
499 $this->version,
500 true
501 );
502 wp_register_script(
503 'wz-' . $this->prefix . '-codemirror',
504 plugins_url( 'js/apply-cm' . $minimize . '.js', __FILE__ ),
505 array( 'jquery', 'underscore', 'code-editor' ),
506 $this->version,
507 true
508 );
509 wp_register_script(
510 'wz-' . $this->prefix . '-media-selector',
511 plugins_url( 'js/media-selector' . $minimize . '.js', __FILE__ ),
512 array( 'jquery', 'media-editor', 'media-views' ),
513 $this->version,
514 true
515 );
516 wp_register_style(
517 'wz-' . $this->prefix . '-admin',
518 plugins_url( 'css/admin-style' . $minimize . '.css', __FILE__ ),
519 array( 'wp-color-picker' ),
520 $this->version
521 );
522
523 // Tom Select scripts and styles.
524 wp_register_style(
525 'wz-' . $this->prefix . '-tom-select',
526 plugins_url( 'css/tom-select.min.css', __FILE__ ),
527 array(),
528 $this->version
529 );
530 wp_register_script(
531 'wz-' . $this->prefix . '-tom-select',
532 plugins_url( 'js/tom-select.complete.min.js', __FILE__ ),
533 array( 'jquery' ),
534 $this->version,
535 true
536 );
537 wp_register_script(
538 'wz-' . $this->prefix . '-tom-select-init',
539 plugin_dir_url( __FILE__ ) . 'js/tom-select-init' . $minimize . '.js',
540 array( 'jquery', 'wz-' . $this->prefix . '-tom-select' ),
541 $this->version,
542 true
543 );
544 wp_localize_script(
545 "wz-{$this->prefix}-admin",
546 'WZSettingsAdmin',
547 array(
548 'prefix' => $this->prefix,
549 'settings_key' => $this->settings_key,
550 'strings' => array(
551 'search_no_results' => esc_html( $this->translation_strings['search_no_results'] ?? 'No settings found. Try a different search term.' ),
552 'search_results_single' => esc_html( $this->translation_strings['search_results_single'] ?? '%d setting found.' ),
553 'search_results_plural' => esc_html( $this->translation_strings['search_results_plural'] ?? '%d settings found.' ),
554 'search_matches_label' => esc_html( $this->translation_strings['search_matches_label'] ?? 'matching settings' ),
555 ),
556 )
557 );
558
559 if ( $hook === $this->settings_page ) {
560 $args = array(
561 'strings' => array(
562 'no_results' => isset( $this->translation_strings['tom_select_no_results'] ) ? esc_html( $this->translation_strings['tom_select_no_results'] ) : 'No results found for "%s"',
563 ),
564 );
565 self::enqueue_scripts_styles( $this->prefix, $args );
566 }
567 }
568
569 /**
570 * Enqueues all scripts, styles, settings, and templates necessary to use the Settings API.
571 *
572 * @param string $prefix Prefix which is used for creating the unique filters and actions.
573 * @param array $args Array of arguments.
574 */
575 public static function enqueue_scripts_styles( $prefix, $args = array() ) {
576
577 wp_enqueue_media();
578
579 wp_enqueue_code_editor(
580 array(
581 'type' => 'text/html',
582 'codemirror' => array(
583 'indentUnit' => 2,
584 'tabSize' => 2,
585 ),
586 )
587 );
588
589 wp_enqueue_script( "wz-{$prefix}-admin" );
590 wp_enqueue_script( "wz-{$prefix}-codemirror" );
591 wp_enqueue_script( "wz-{$prefix}-media-selector" );
592
593 // Enqueue Tom Select.
594 wp_enqueue_style( "wz-{$prefix}-tom-select" );
595 wp_enqueue_script( "wz-{$prefix}-tom-select" );
596
597 $defaults = array(
598 'endpoint' => 'category',
599 'strings' => array(
600 'no_results' => 'No results found for "%s"',
601 ),
602 );
603
604 $args = wp_parse_args( $args, $defaults );
605
606 // Localize Tom Select settings.
607 wp_localize_script(
608 "wz-{$prefix}-tom-select-init",
609 'WZTomSelectSettings',
610 $args
611 );
612 wp_enqueue_script( "wz-{$prefix}-tom-select-init" );
613
614 wp_enqueue_style( 'wz-' . $prefix . '-admin' );
615 }
616
617 /**
618 * Initialize and registers the settings sections and fields to WordPress
619 *
620 * Usually this should be called at `admin_init` hook.
621 *
622 * This public function gets the initiated settings sections and fields. Then
623 * registers them to WordPress and ready for use.
624 */
625 public function admin_init() {
626
627 $settings_key = $this->settings_key;
628
629 if ( false === get_option( $settings_key ) ) {
630 add_option( $settings_key, $this->settings_defaults() );
631 }
632
633 $this->settings_form = new Settings_Form(
634 array(
635 'settings_key' => $settings_key,
636 'prefix' => $this->prefix,
637 'translation_strings' => $this->translation_strings,
638 )
639 );
640
641 foreach ( $this->registered_settings as $section => $settings ) {
642
643 add_settings_section(
644 "{$settings_key}_{$section}", // ID used to identify this section and with which to register options.
645 '', // No title, we will handle this via a separate function.
646 '__return_false', // No callback function needed. We'll process this separately.
647 "{$settings_key}_{$section}" // Page on which these options will be added.
648 );
649
650 foreach ( $settings as $setting ) {
651
652 $args = self::parse_field_args( $setting, $section );
653
654 $id = $args['id'];
655 $name = $args['name'];
656 $type = isset( $args['type'] ) ? $args['type'] : 'text';
657 $callback = method_exists( $this->settings_form, "callback_{$type}" ) ? array( $this->settings_form, "callback_{$type}" ) : array( $this->settings_form, 'callback_missing' );
658
659 // Tag header rows so the settings search can group fields under them.
660 if ( 'header' === $type ) {
661 $args['class'] = trim( ( $args['class'] ?? '' ) . ' wz-settings-header-row' );
662 }
663
664 add_settings_field(
665 "{$settings_key}[{$id}]", // ID of the settings field. We save it within the settings array.
666 $name, // Label of the setting.
667 $callback, // Function to handle the setting.
668 "{$settings_key}_{$section}", // Page to display the setting. In our case it is the section as defined above.
669 "{$settings_key}_{$section}", // Name of the section.
670 $args
671 );
672 }
673 }
674
675 // Register the settings into the options table.
676 register_setting(
677 $settings_key,
678 $settings_key,
679 array(
680 'sanitize_callback' => array( $this, 'settings_sanitize' ),
681 'show_in_rest' => true,
682 )
683 );
684 }
685
686 /**
687 * Flattens $this->registered_settings into $setting[id] => $setting[type] format.
688 *
689 * @return array Default settings
690 */
691 public function get_registered_settings_types() {
692
693 $options = array();
694
695 // Populate some default values.
696 foreach ( $this->registered_settings as $tab => $settings ) {
697 foreach ( $settings as $option ) {
698 $options[ $option['id'] ] = $option['type'];
699 }
700 }
701
702 /**
703 * Filters the settings array.
704 *
705 * @param array $options Default settings.
706 */
707 return apply_filters( $this->prefix . '_get_settings_types', $options ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
708 }
709
710
711 /**
712 * Default settings.
713 *
714 * @return array Default settings
715 */
716 public function settings_defaults() {
717
718 $options = array();
719
720 // Populate some default values.
721 foreach ( $this->registered_settings as $tab => $settings ) {
722 foreach ( $settings as $option ) {
723 /**
724 * Skip settings that are not really settings.
725 *
726 * @param array $non_setting_types Array of types which are not settings.
727 */
728 $non_setting_types = apply_filters( $this->prefix . '_non_setting_types', array( 'header', 'descriptive_text' ) ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
729
730 if ( in_array( $option['type'], $non_setting_types, true ) ) {
731 continue;
732 }
733
734 // Base default per type.
735 $options[ $option['id'] ] = ( 'checkbox' === $option['type'] ) ? 0 : '';
736
737 // Prefer the explicit 'default' key when provided.
738 if ( isset( $option['default'] ) ) {
739 $options[ $option['id'] ] = $option['default'];
740 } else {
741 // Back-compat for legacy configs that used 'options' to store default values for text-like fields.
742 if ( in_array( $option['type'], array( 'textarea', 'css', 'html', 'text', 'url', 'csv', 'color', 'numbercsv', 'postids', 'posttypes', 'number', 'wysiwyg', 'file', 'password' ), true ) && isset( $option['options'] ) ) {
743 $options[ $option['id'] ] = $option['options'];
744 }
745
746 // Back-compat: when checkbox used 'options' truthy to indicate checked by default.
747 if ( 'checkbox' === $option['type'] && ! empty( $option['options'] ) ) {
748 $options[ $option['id'] ] = 1;
749 }
750 }
751 }
752 }
753
754 $options = array_merge( $options, $this->upgraded_settings );
755
756 /**
757 * Filters the default settings array.
758 *
759 * @param array $options Default settings.
760 */
761 return apply_filters( $this->prefix . '_settings_defaults', $options ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
762 }
763
764
765 /**
766 * Get the default option for a specific key
767 *
768 * @param string $key Key of the option to fetch.
769 * @return mixed
770 */
771 public function get_default_option( $key = '' ) {
772
773 $default_settings = $this->settings_defaults();
774
775 if ( array_key_exists( $key, $default_settings ) ) {
776 return $default_settings[ $key ];
777 } else {
778 return false;
779 }
780 }
781
782
783 /**
784 * Reset settings.
785 *
786 * @return void
787 */
788 public function settings_reset() {
789 delete_option( $this->settings_key );
790 }
791
792 /**
793 * Get sanitization callback for given Settings key.
794 *
795 * @param string $key Settings key.
796 *
797 * @return mixed Callback function or false if callback isn't found.
798 */
799 public function get_sanitize_callback( $key = '' ) {
800 if ( empty( $key ) ) {
801 return false;
802 }
803
804 $settings_sanitize = new Settings_Sanitize(
805 array(
806 'settings_key' => $this->settings_key,
807 'prefix' => $this->prefix,
808 )
809 );
810
811 // Iterate over registered fields and see if we can find proper callback.
812 foreach ( $this->registered_settings as $section => $settings ) {
813 foreach ( $settings as $setting ) {
814 if ( $setting['id'] !== $key ) {
815 continue;
816 }
817
818 // Return the callback name.
819 $sanitize_callback = false;
820
821 if ( isset( $setting['sanitize_callback'] ) && is_callable( $setting['sanitize_callback'] ) ) {
822 $sanitize_callback = $setting['sanitize_callback'];
823 return $sanitize_callback;
824 }
825
826 if ( is_callable( array( $settings_sanitize, 'sanitize_' . $setting['type'] . '_field' ) ) ) {
827 // For repeater fields, create a closure to pass the field configuration.
828 if ( 'repeater' === $setting['type'] ) {
829 return function ( $value ) use ( $settings_sanitize, $setting ) {
830 return $settings_sanitize->sanitize_repeater_field( $value, $setting );
831 };
832 }
833 $sanitize_callback = array( $settings_sanitize, 'sanitize_' . $setting['type'] . '_field' );
834 return $sanitize_callback;
835 }
836
837 return $sanitize_callback;
838 }
839 }
840
841 return false;
842 }
843
844 /**
845 * Sanitize the form data being submitted.
846 *
847 * @param array $input Input unclean array.
848 * @return array Sanitized array
849 */
850 public function settings_sanitize( $input ) {
851 // This should be set if a form is submitted, so let's save it in the $referrer variable.
852 if ( empty( $_POST['_wp_http_referer'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
853 return $input;
854 }
855
856 parse_str( sanitize_text_field( wp_unslash( $_POST['_wp_http_referer'] ) ), $referrer ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
857
858 // Check if we need to set to defaults.
859 $reset = isset( $_POST['settings_reset'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
860
861 if ( $reset ) {
862 $this->settings_reset();
863 $settings = get_option( $this->settings_key );
864
865 add_settings_error( $this->prefix . '-notices', '', $this->translation_strings['reset_message'], 'error' );
866
867 return $settings;
868 }
869
870 // Get the various settings we've registered.
871 $settings = get_option( $this->settings_key );
872 $settings = is_array( $settings ) ? $settings : array();
873 $settings_types = $this->get_registered_settings_types();
874
875 // Get the tab. This is also our settings' section.
876 $tab = $referrer['tab'] ?? $this->default_tab;
877
878 $input = $input ? $input : array();
879
880 /**
881 * Filter the settings for the tab. e.g. prefix_settings_general_sanitize.
882 *
883 * @param array $input Input unclean array
884 */
885 $input = apply_filters( $this->prefix . '_settings_' . $tab . '_sanitize', $input ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
886
887 // Create an output array by merging the existing settings with the ones submitted.
888 $output = array_merge( $settings, $input );
889
890 // Loop through each setting being saved and pass it through a sanitization filter.
891 foreach ( $settings_types as $key => $type ) {
892 /**
893 * Skip settings that are not really settings.
894 *
895 * @param array $non_setting_types Array of types which are not settings.
896 */
897 $non_setting_types = apply_filters( $this->prefix . '_non_setting_types', array( 'header', 'descriptive_text' ) ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
898
899 if ( in_array( $type, $non_setting_types, true ) ) {
900 continue;
901 }
902
903 if ( array_key_exists( $key, $input ) ) {
904 $sanitize_callback = $this->get_sanitize_callback( $key );
905
906 // If callback is set, call it.
907 if ( $sanitize_callback ) {
908 if ( 'sensitive' === $type ) {
909 $output[ $key ] = call_user_func( $sanitize_callback, $input[ $key ], $key );
910 } else {
911 $output[ $key ] = call_user_func( $sanitize_callback, $input[ $key ] );
912 }
913 continue;
914 }
915 }
916
917 // Delete any key that is not present when we submit the input array.
918 if ( ! isset( $input[ $key ] ) ) {
919 unset( $output[ $key ] );
920 }
921
922 // Delete any settings that are no longer part of our registered settings.
923 if ( array_key_exists( $key, $output ) && ! array_key_exists( $key, $settings_types ) ) {
924 unset( $output[ $key ] );
925 }
926 }
927
928 add_settings_error( $this->prefix . '-notices', '', $this->translation_strings['success_message'], 'updated' );
929
930 /**
931 * Filter the settings array before it is returned.
932 *
933 * @param array $output Settings array.
934 * @param array $input Input settings array.
935 */
936 return apply_filters( $this->prefix . '_settings_sanitize', $output, $input ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
937 }
938
939 /**
940 * Render the settings page.
941 */
942 public function plugin_settings() {
943 ?>
944 <div class="wrap">
945 <?php do_action( $this->prefix . '_settings_page_header_before' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound ?>
946 <h1><?php echo esc_html( $this->translation_strings['page_header'] ); ?></h1>
947 <?php do_action( $this->prefix . '_settings_page_header' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound ?>
948
949 <?php
950 // WordPress automatically calls settings_errors() on Settings pages.
951 // Only call it manually on custom menu pages to prevent duplicates.
952 $current_screen = get_current_screen();
953 if ( $current_screen && 0 !== strpos( $current_screen->base, 'settings_page_' ) ) {
954 settings_errors( $this->prefix . '-notices' );
955 }
956 ?>
957
958 <div id="poststuff">
959 <div id="post-body" class="metabox-holder columns-2 wz-settings-post-body">
960 <div id="post-body-content" class="wz-vertical-tabs">
961
962 <?php $this->show_navigation(); ?>
963 <?php $this->show_form(); ?>
964
965 </div><!-- /#post-body-content -->
966
967 <div id="postbox-container-1" class="postbox-container">
968
969 <div id="side-sortables" class="meta-box-sortables ui-sortable">
970 <?php
971 $sidebar_file = dirname( __DIR__ ) . '/sidebar.php';
972 if ( file_exists( $sidebar_file ) ) {
973 include_once $sidebar_file;
974 }
975 ?>
976 </div><!-- /#side-sortables -->
977
978 </div><!-- /#postbox-container-1 -->
979 </div><!-- /#post-body -->
980 <br class="clear" />
981 </div><!-- /#poststuff -->
982
983 </div><!-- /.wrap -->
984
985 <?php
986 }
987
988 /**
989 * Show navigations as tab
990 *
991 * Shows all the settings section labels as tab
992 */
993 public function show_navigation() {
994 $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
995
996 $count = count( $this->settings_sections );
997
998 // Don't show the navigation if only one section exists.
999 if ( 1 === $count ) {
1000 return;
1001 }
1002
1003 $html = '<ul class="nav-tab-wrapper">';
1004
1005 // Settings search box. Rendered via wp_kses() with an extended allowed list below as
1006 // wp_kses_post() strips input and button tags.
1007 $search_box = sprintf(
1008 '<li class="wz-settings-search-wrap">' .
1009 '<label class="screen-reader-text" for="%1$s">%2$s</label>' .
1010 '<span class="wz-settings-search-box">' .
1011 '<input type="search" id="%1$s" class="wz-settings-search" placeholder="%2$s" autocomplete="off" />' .
1012 '<button type="button" class="wz-settings-search-clear" aria-label="%3$s" hidden><span aria-hidden="true">&times;</span></button>' .
1013 '</span>' .
1014 '<span class="wz-settings-search-status screen-reader-text" role="status" aria-live="polite"></span>' .
1015 '</li>',
1016 esc_attr( "{$this->prefix}-settings-search" ),
1017 esc_attr( $this->translation_strings['search_placeholder'] ?? 'Search settings' ),
1018 esc_attr( $this->translation_strings['search_clear'] ?? 'Clear search' )
1019 );
1020
1021 foreach ( $this->settings_sections as $tab_id => $tab_name ) {
1022
1023 $active = $active_tab === $tab_id ? ' ' : '';
1024
1025 $html .= sprintf(
1026 '<li><a href="#%s" title="%s" class="nav-tab %s">%s</a></li>',
1027 esc_attr( $tab_id ),
1028 esc_attr( $tab_name ),
1029 sanitize_html_class( $active ),
1030 esc_html( $tab_name )
1031 );
1032
1033 }
1034
1035 $html .= '</ul>';
1036
1037 $allowed_html = wp_kses_allowed_html( 'post' );
1038 $allowed_html['input'] = array(
1039 'type' => true,
1040 'id' => true,
1041 'class' => true,
1042 'placeholder' => true,
1043 'autocomplete' => true,
1044 );
1045 $allowed_html['button'] = array(
1046 'type' => true,
1047 'class' => true,
1048 'aria-label' => true,
1049 'hidden' => true,
1050 );
1051 $allowed_html['span'] = array_merge(
1052 (array) ( $allowed_html['span'] ?? array() ),
1053 array(
1054 'class' => true,
1055 'role' => true,
1056 'aria-live' => true,
1057 'aria-hidden' => true,
1058 )
1059 );
1060
1061 $html = str_replace( '<ul class="nav-tab-wrapper">', '<ul class="nav-tab-wrapper">' . $search_box, $html );
1062
1063 echo wp_kses( $html, $allowed_html );
1064 }
1065
1066 /**
1067 * Show the section settings forms
1068 *
1069 * This public function displays every sections in a different form
1070 */
1071 public function show_form() {
1072 ?>
1073
1074 <form method="post" action="options.php" id="<?php echo esc_attr( "{$this->prefix}-settings-form" ); ?>">
1075
1076 <?php settings_fields( $this->settings_key ); ?>
1077
1078 <?php foreach ( $this->settings_sections as $tab_id => $tab_name ) : ?>
1079
1080 <div id="<?php echo esc_attr( $tab_id ); ?>">
1081 <h2 class="wz-section-title" tabindex="-1"><?php echo esc_html( $tab_name ); ?></h2>
1082 <table class="form-table">
1083 <?php
1084 do_settings_fields( $this->prefix . '_settings_' . $tab_id, $this->prefix . '_settings_' . $tab_id );
1085 ?>
1086 </table>
1087 <p>
1088 <?php
1089 // Default submit button.
1090 submit_button(
1091 $this->translation_strings['save_changes'],
1092 'primary',
1093 'submit',
1094 false
1095 );
1096
1097 echo '&nbsp;&nbsp;';
1098
1099 // Reset button.
1100 $confirm = esc_js( $this->translation_strings['reset_button_confirm'] );
1101 submit_button(
1102 $this->translation_strings['reset_settings'],
1103 'secondary',
1104 'settings_reset',
1105 false,
1106 array(
1107 'onclick' => "return confirm('{$confirm}');",
1108 )
1109 );
1110
1111 echo '&nbsp;&nbsp;';
1112
1113 /**
1114 * Action to add more buttons in each tab.
1115 *
1116 * @param string $tab_id Tab ID.
1117 * @param string $tab_name Tab name.
1118 * @param array $settings_sections Settings sections.
1119 */
1120 do_action( $this->prefix . '_settings_form_buttons', $tab_id, $tab_name, $this->settings_sections ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
1121 ?>
1122 </p>
1123 <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>
1124 </div><!-- /#tab_id-->
1125
1126 <?php endforeach; ?>
1127
1128 </form>
1129
1130 <?php
1131 }
1132
1133 /**
1134 * Add rating links to the admin dashboard
1135 *
1136 * @param string $footer_text The existing footer text.
1137 * @return string Updated Footer text
1138 */
1139 public function admin_footer_text( $footer_text ) {
1140
1141 if ( ! empty( $this->admin_footer_text ) && get_current_screen()->id === $this->settings_page ) {
1142
1143 $text = $this->admin_footer_text;
1144
1145 return str_replace( '</span>', '', $footer_text ) . ' | ' . $text . '</span>';
1146 } else {
1147 return $footer_text;
1148 }
1149 }
1150
1151 /**
1152 * Function to add the contextual help in the settings page.
1153 */
1154 public function settings_help() {
1155 $screen = get_current_screen();
1156
1157 if ( $screen->id !== $this->settings_page ) {
1158 return;
1159 }
1160
1161 $screen->set_help_sidebar( $this->help_sidebar );
1162
1163 foreach ( $this->help_tabs as $tab ) {
1164 $screen->add_help_tab( $tab );
1165 }
1166 }
1167
1168 /**
1169 * Parse field arguments with defaults.
1170 *
1171 * @param array $field Field arguments.
1172 * @param string $section Section name.
1173 *
1174 * @return array Parsed field arguments.
1175 */
1176 public static function parse_field_args( $field, $section = '' ) {
1177 $defaults = array(
1178 'id' => null,
1179 'name' => '',
1180 'desc' => '',
1181 'type' => 'text',
1182 'size' => null,
1183 'options' => '',
1184 'default' => '',
1185 'min' => 0,
1186 'max' => 999999,
1187 'step' => 1,
1188 'field_class' => '',
1189 'field_attributes' => array(),
1190 'placeholder' => '',
1191 'readonly' => false,
1192 'required' => false,
1193 'disabled' => false,
1194 'pro' => false,
1195 'section' => $section,
1196 );
1197
1198 $field = wp_parse_args( $field, $defaults );
1199
1200 // Add required indicator to field name if the field is required.
1201 if ( ! empty( $field['required'] ) && true === $field['required'] ) {
1202 $field['name'] = sprintf( '%s <span class="required" title="%s">*</span>', $field['name'], 'Required' );
1203 }
1204
1205 return $field;
1206 }
1207
1208 /**
1209 * Get the encryption key for API key encryption/decryption.
1210 *
1211 * @param string $prefix Optional prefix for fallback key.
1212 * @return string The encryption key.
1213 */
1214 public static function get_encryption_key( $prefix = '' ) {
1215 $fallback = $prefix ? str_replace( '-', '_', $prefix ) . '_encryption_fallback' : 'settings_api_encryption_fallback';
1216 return defined( 'AUTH_SALT' ) ? AUTH_SALT : ( defined( 'SECURE_AUTH_SALT' ) ? SECURE_AUTH_SALT : hash( 'sha256', __NAMESPACE__ . $fallback ) );
1217 }
1218
1219 /**
1220 * Encrypts an API key using either OpenSSL or Sodium, if available.
1221 *
1222 * @param string $key The API key to encrypt.
1223 * @param string $prefix Optional prefix for fallback key.
1224 * @return string The encrypted API key, or the plain text key if no secure method is available.
1225 */
1226 public static function encrypt_api_key( $key, $prefix = '' ) {
1227 if ( empty( $key ) ) {
1228 return '';
1229 }
1230
1231 // Use OpenSSL if available.
1232 if ( extension_loaded( 'openssl' ) ) {
1233 $iv_length = openssl_cipher_iv_length( 'aes-256-cbc' );
1234 $iv = openssl_random_pseudo_bytes( $iv_length );
1235 $encrypted = openssl_encrypt( $key, 'aes-256-cbc', self::get_encryption_key( $prefix ), 0, $iv );
1236
1237 // Store IV + ciphertext in hex format.
1238 return 'enc:' . bin2hex( $iv . $encrypted );
1239 }
1240
1241 // Use Sodium (libsodium) if OpenSSL is unavailable.
1242 if ( extension_loaded( 'sodium' ) ) {
1243 $sodium_key = substr( hash( 'sha256', self::get_encryption_key( $prefix ), true ), 0, SODIUM_CRYPTO_SECRETBOX_KEYBYTES );
1244 $nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
1245 $encrypted = sodium_crypto_secretbox( $key, $nonce, $sodium_key );
1246
1247 return 'enc:' . sodium_bin2hex( $nonce . $encrypted );
1248 }
1249
1250 return $key;
1251 }
1252
1253 /**
1254 * Decrypts an API key using either OpenSSL or Sodium, if available.
1255 *
1256 * @param string $encrypted_key The encrypted API key to decrypt.
1257 * @param string $prefix Optional prefix for fallback key.
1258 * @return string The decrypted API key, or the encrypted key if no secure method is available.
1259 */
1260 public static function decrypt_api_key( $encrypted_key, $prefix = '' ) {
1261 if ( empty( $encrypted_key ) ) {
1262 return '';
1263 }
1264
1265 // If the key doesn't start with 'enc:', it's not encrypted.
1266 if ( strpos( $encrypted_key, 'enc:' ) !== 0 ) {
1267 return $encrypted_key;
1268 }
1269
1270 // Remove the 'enc:' prefix.
1271 $encrypted_key = substr( $encrypted_key, 4 );
1272
1273 // Try OpenSSL decryption.
1274 if ( extension_loaded( 'openssl' ) ) {
1275 $data = hex2bin( $encrypted_key );
1276 if ( false === $data ) {
1277 return '';
1278 }
1279
1280 $iv_length = openssl_cipher_iv_length( 'aes-256-cbc' );
1281 $iv = mb_substr( $data, 0, $iv_length, '8bit' );
1282 $ciphertext = mb_substr( $data, $iv_length, null, '8bit' );
1283
1284 $decrypted = openssl_decrypt( $ciphertext, 'aes-256-cbc', self::get_encryption_key( $prefix ), 0, $iv );
1285 return false === $decrypted ? '' : $decrypted;
1286 }
1287
1288 // Try Sodium (libsodium) decryption.
1289 if ( extension_loaded( 'sodium' ) ) {
1290 $sodium_key = substr( hash( 'sha256', self::get_encryption_key( $prefix ), true ), 0, SODIUM_CRYPTO_SECRETBOX_KEYBYTES );
1291 $decoded = sodium_hex2bin( $encrypted_key );
1292
1293 if ( ! $decoded ) {
1294 return '';
1295 }
1296
1297 $nonce = mb_substr( $decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, '8bit' );
1298 $ciphertext = mb_substr( $decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, null, '8bit' );
1299 $decrypted = sodium_crypto_secretbox_open( $ciphertext, $nonce, $sodium_key );
1300
1301 return false === $decrypted ? '' : $decrypted;
1302 }
1303
1304 return $encrypted_key;
1305 }
1306 }
1307