json_decode() -> array stored via update_option(). * - Save simple scalars (e.g., "On"/"Off") as-is. * - Load returns stored value (array/scalar). * - Enqueues small JS/CSS that provide generic save/load helpers with busy (spinner) UI. * * file: ../includes/save-load-option/save-load-option.php * * Data attributes on clickable elements: * Save: * data-wpbc-u-save-name — option key (required) * data-wpbc-u-save-nonce — nonce value (required for SAVE) * data-wpbc-u-save-action — nonce action (required for SAVE) * data-wpbc-u-save-value — RAW scalar to save (optional) * data-wpbc-u-save-value-json— JSON string to save (optional) * data-wpbc-u-save-fields — CSV of selectors; values serialized with jQuery.param (optional) * data-wpbc-u-busy-text — custom text during AJAX (optional) * data-wpbc-u-save-callback — window function name to call on success (optional) * * Load: * data-wpbc-u-load-name — option key (required) * data-wpbc-u-busy-text — custom text during AJAX (optional) * data-wpbc-u-load-callback — window function name to receive loaded value (optional) * * JS Events: * jQuery(document) * .on('wpbc:option:beforeSave', function (e, $el, payload) {}) * .on('wpbc:option:afterSave', function (e, response) {}) * .on('wpbc:option:beforeLoad', function (e, $el, name) {}) * .on('wpbc:option:afterLoad', function (e, response) {}) * * @package Booking Calendar * @author wpdevelop * @since 11.0.0 * @version 1.0.1 */ if ( ! defined( 'ABSPATH' ) ) { exit; } class wpbc_option_saver_loader { private static $ajax_action_save = 'wpbc_ajax_option_save'; private static $ajax_action_load = 'wpbc_ajax_option_load'; private static $option_prefix = ''; private static $asset_version = '1.0.1'; private static $save_policies = array(); public static function init() { add_action( 'init', array( __CLASS__, 'register_ajax_handlers' ) ); add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) ); } /** * Register an option-specific save policy. * * Supported policy keys: * - can_save callable(): bool * - permission_message string * - normalize_raw callable( mixed $data_raw, string $data_name ): mixed * - force_mode string * - allowed_keys array|callable(): array * - normalize_item callable( string $option_key, mixed $value, string $data_name ): mixed * * @param string $option_name Option name from data_name. * @param array $policy Policy definition. * * @return void */ public static function register_option_policy( $option_name, $policy ) { $option_name = sanitize_key( (string) $option_name ); if ( empty( $option_name ) || ! is_array( $policy ) ) { return; } self::$save_policies[ $option_name ] = $policy; } /** * Get a registered option save policy. * * @param string $option_name Option name from data_name. * * @return array */ private static function get_option_policy( $option_name ) { $option_name = sanitize_key( (string) $option_name ); return ( isset( self::$save_policies[ $option_name ] ) && is_array( self::$save_policies[ $option_name ] ) ) ? self::$save_policies[ $option_name ] : array(); } /** * Get allowed option keys from a policy. * * @param array $policy Policy definition. * * @return array */ private static function get_policy_allowed_keys( $policy ) { if ( empty( $policy['allowed_keys'] ) ) { return array(); } $allowed_keys = is_callable( $policy['allowed_keys'] ) ? call_user_func( $policy['allowed_keys'] ) : $policy['allowed_keys']; if ( ! is_array( $allowed_keys ) ) { return array(); } return array_values( array_filter( array_map( 'sanitize_key', array_map( 'strval', $allowed_keys ) ) ) ); } /** * Register AJAX handlers (logged-in admin). * * @return void */ public static function register_ajax_handlers() { add_action( 'wp_ajax_' . self::$ajax_action_save, array( __CLASS__, 'handle_ajax_save' ) ); add_action( 'wp_ajax_' . self::$ajax_action_load, array( __CLASS__, 'handle_ajax_load' ) ); } /** * Enqueue JS/CSS for admin pages. * * @return void */ public static function enqueue_assets() { // Optional screen check. if ( function_exists( 'get_current_screen' ) ) { $screen = get_current_screen(); $ok = apply_filters( 'wpbc_option_saver_loader_enqueue', true, $screen ); if ( ! $ok ) { return; } } $base_url = plugins_url( '', defined( 'WPBC_FILE' ) ? WPBC_FILE : __FILE__ ); $js_url = $base_url . '/includes/save-load-option/_out/save-load-option.js'; $css_url = $base_url . '/includes/save-load-option/_out/save-load-option.css'; wp_register_style( 'wpbc-save-load-option', $css_url, array(), self::$asset_version ); wp_enqueue_style( 'wpbc-save-load-option' ); wp_register_script( 'wpbc-save-load-option', $js_url, array( 'jquery' ), self::$asset_version, true ); wp_enqueue_script( 'wpbc-save-load-option' ); wp_localize_script( 'wpbc-save-load-option', 'wpbc_option_saver_loader_config', array( 'ajax_url' => admin_url( 'admin-ajax.php' ), 'action_save' => self::$ajax_action_save, 'action_load' => self::$ajax_action_load, ) ); } /** * AJAX: Save option. * * Expected POST: * - data_name string Option key. * - data_value string RAW scalar | query-string | JSON string. * - nonce_action string Nonce action name. * - nonce string Nonce value. * * @return void */ public static function handle_ajax_save() { $capability = apply_filters( 'wpbc_option_saver_loader_cap_save', ( function_exists( 'wpbc_bfb_get_manage_cap' ) ) ? wpbc_bfb_get_manage_cap() : 'manage_options' ); if ( ! current_user_can( $capability ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to save settings.', 'booking' ) ) ); } $data_name = isset( $_POST['data_name'] ) ? sanitize_key( wp_unslash( $_POST['data_name'] ) ) : ''; /* phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing */ $data_raw = isset( $_POST['data_value'] ) ? wp_unslash( $_POST['data_value'] ) : ''; // Optional: split JSON object into multiple options. $data_mode = isset( $_POST['data_mode'] ) ? sanitize_key( wp_unslash( $_POST['data_mode'] ) ) : ''; $data_fields = isset( $_POST['data_fields'] ) ? sanitize_text_field( wp_unslash( $_POST['data_fields'] ) ) : ''; $nonce_name = isset( $_POST['nonce_action'] ) ? sanitize_key( wp_unslash( $_POST['nonce_action'] ) ) : ''; $nonce_value = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : ''; if ( empty( $nonce_name ) || ! wp_verify_nonce( $nonce_value, $nonce_name ) ) { wp_send_json_error( array( 'message' => __( 'Invalid nonce.', 'booking' ) ) ); } if ( empty( $data_name ) ) { wp_send_json_error( array( 'message' => __( 'Missing data name.', 'booking' ) ) ); } $save_policy = self::get_option_policy( $data_name ); if ( ! empty( $save_policy['can_save'] ) && is_callable( $save_policy['can_save'] ) && ! call_user_func( $save_policy['can_save'], $data_name ) ) { $permission_message = ( ! empty( $save_policy['permission_message'] ) && is_string( $save_policy['permission_message'] ) ) ? $save_policy['permission_message'] : __( 'You do not have permission to save this option.', 'booking' ); wp_send_json_error( array( 'message' => $permission_message ) ); } if ( ! empty( $save_policy['normalize_raw'] ) && is_callable( $save_policy['normalize_raw'] ) ) { $data_raw = call_user_func( $save_policy['normalize_raw'], $data_raw, $data_name ); } if ( ! empty( $save_policy['force_mode'] ) ) { $data_mode = sanitize_key( (string) $save_policy['force_mode'] ); } $policy_allowed_keys = self::get_policy_allowed_keys( $save_policy ); if ( ! empty( $policy_allowed_keys ) ) { $data_fields = implode( ',', $policy_allowed_keys ); } $value_to_store = self::normalize_incoming_value( $data_raw ); // Split mode: JSON object => multiple options saved separately. if ( 'split' === $data_mode && is_array( $value_to_store ) ) { $allowed_keys = array(); if ( '' !== trim( $data_fields ) ) { $parts = explode( ',', (string) $data_fields ); foreach ( $parts as $p ) { $k = sanitize_key( trim( (string) $p ) ); if ( '' !== $k ) { $allowed_keys[ $k ] = true; } } } $saved = array(); foreach ( $value_to_store as $k => $v ) { if ( ! is_scalar( $k ) ) { continue; } $opt_key = sanitize_key( (string) $k ); if ( '' === $opt_key ) { continue; } // If allowlist provided, only save those keys. if ( ! empty( $allowed_keys ) && ! isset( $allowed_keys[ $opt_key ] ) ) { continue; } // Values: allow scalar or arrays (already sanitized by normalize_incoming_value()). $opt_val = $v; if ( is_scalar( $opt_val ) ) { $opt_val = sanitize_text_field( (string) $opt_val ); } elseif ( is_array( $opt_val ) ) { $opt_val = self::sanitize_mixed_value( $opt_val ); } else { $opt_val = ''; } if ( ! empty( $save_policy['normalize_item'] ) && is_callable( $save_policy['normalize_item'] ) ) { $opt_val = call_user_func( $save_policy['normalize_item'], $opt_key, $opt_val, $data_name ); } self::update_option( self::$option_prefix . $opt_key, $opt_val ); $saved[ $opt_key ] = $opt_val; } if ( empty( $saved ) ) { wp_send_json_error( array( 'message' => __( 'Nothing to save.', 'booking' ) ) ); } wp_send_json_success( array( 'message' => __( 'Settings saved.', 'booking' ), 'value' => $saved, 'mode' => 'split', ) ); } // Default: store as a single option (scalar/array). self::update_option( self::$option_prefix . $data_name, $value_to_store ); // Return stored value (useful for client callbacks / UI sync). wp_send_json_success( array( 'message' => __( 'Settings saved.', 'booking' ), 'value' => $value_to_store, ) ); } /** * AJAX: Load option. * * Expected GET: * - data_name string Option key. * * @return void */ public static function handle_ajax_load() { $capability = apply_filters( 'wpbc_option_saver_loader_cap_load', ( function_exists( 'wpbc_bfb_get_manage_cap' ) ) ? wpbc_bfb_get_manage_cap() : 'manage_options' ); if ( ! current_user_can( $capability ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to load settings.', 'booking' ) ) ); } /* phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing */ $data_name = isset( $_GET['data_name'] ) ? sanitize_key( wp_unslash( $_GET['data_name'] ) ) : ''; if ( empty( $data_name ) ) { wp_send_json_error( array( 'message' => __( 'Missing data name.', 'booking' ) ) ); } $option_key = self::$option_prefix . $data_name; $value = self::get_option( $option_key, array() ); wp_send_json_success( array( 'value' => $value ) ); } /** * Normalize payload: prefer JSON -> array; fallback to query-string -> array; else scalar string. * * @param string $data_raw Raw input. * @return mixed */ private static function normalize_incoming_value( $data_raw ) { if ( ! is_string( $data_raw ) || '' === $data_raw ) { return ''; } $maybe_json = trim( $data_raw ); // JSON path. if ( 0 === strpos( $maybe_json, '{' ) || 0 === strpos( $maybe_json, '[' ) || 'null' === strtolower( $maybe_json ) || 'true' === strtolower( $maybe_json ) || 'false' === strtolower( $maybe_json ) || is_numeric( $maybe_json ) ) { $decoded = json_decode( $maybe_json, true ); if ( null !== $decoded && JSON_ERROR_NONE === json_last_error() ) { return self::sanitize_mixed_value( $decoded ); } } // Query-string path. if ( false !== strpos( $data_raw, '=' ) || false !== strpos( $data_raw, '&' ) || false !== strpos( strtolower( $data_raw ), '%5b' ) ) { $parsed = array(); parse_str( $data_raw, $parsed ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized return self::sanitize_kv_array_preserve_brackets( $parsed ); } // Scalar. return sanitize_text_field( $data_raw ); } /** * Recursively sanitize mixed values. * * @param mixed $value Mixed value. * @return mixed */ private static function sanitize_mixed_value( $value ) { if ( is_array( $value ) ) { $out = array(); foreach ( $value as $k => $v ) { $kk = is_string( $k ) ? preg_replace( '/[^a-zA-Z0-9_\-\[\]]/', '', $k ) : $k; $out[ $kk ] = self::sanitize_mixed_value( $v ); } return $out; } if ( is_scalar( $value ) ) { return sanitize_text_field( (string) $value ); } return ''; } /** * Sanitize arrays parsed from query-string, preserving bracket keys. * * @param array $parsed_data Parsed data. * @return array */ private static function sanitize_kv_array_preserve_brackets( $parsed_data ) { $sanitized_data = array(); if ( empty( $parsed_data ) || ! is_array( $parsed_data ) ) { return $sanitized_data; } foreach ( $parsed_data as $key => $val ) { $key = preg_replace( '/[^a-zA-Z0-9_\-\[\]]/', '', (string) $key ); if ( is_array( $val ) ) { $sanitized_data[ $key ] = self::sanitize_mixed_value( $val ); } else { $sanitized_data[ $key ] = sanitize_text_field( $val ); } } return $sanitized_data; } /** * Update option (Booking Calendar wrapper if present). * * @param string $option_key Key. * @param mixed $value Value. * @return void */ private static function update_option( $option_key, $value ) { if ( function_exists( 'update_bk_option' ) ) { update_bk_option( $option_key, $value ); } else { update_option( $option_key, $value ); } } /** * Get option (Booking Calendar wrapper if present). * * @param string $option_key Key. * @param mixed $default Default. * @return mixed */ private static function get_option( $option_key, $default = false ) { if ( function_exists( 'get_bk_option' ) ) { $val = get_bk_option( $option_key ); return ( null === $val ) ? $default : $val; } return get_option( $option_key, $default ); } } require_once __DIR__ . '/option-save-policies.php'; add_action( 'plugins_loaded', array( 'wpbc_option_saver_loader', 'init' ) ); /** * == Usage examples == * * 1) Save RAW scalar (On/Off). * * * 2) Save complex structure (RAW JSON) * * * 3) Load option and apply * * */