WordPress 4.3
[autoinstalls/wordpress.git] / wp-includes / class-wp-customize-setting.php
1 <?php
2 /**
3  * WordPress Customize Setting classes
4  *
5  * @package WordPress
6  * @subpackage Customize
7  * @since 3.4.0
8  */
9
10 /**
11  * Customize Setting class.
12  *
13  * Handles saving and sanitizing of settings.
14  *
15  * @since 3.4.0
16  *
17  * @see WP_Customize_Manager
18  */
19 class WP_Customize_Setting {
20         /**
21          * @access public
22          * @var WP_Customize_Manager
23          */
24         public $manager;
25
26         /**
27          * @access public
28          * @var string
29          */
30         public $id;
31
32         /**
33          * @access public
34          * @var string
35          */
36         public $type = 'theme_mod';
37
38         /**
39          * Capability required to edit this setting.
40          *
41          * @var string
42          */
43         public $capability = 'edit_theme_options';
44
45         /**
46          * Feature a theme is required to support to enable this setting.
47          *
48          * @access public
49          * @var string
50          */
51         public $theme_supports  = '';
52         public $default         = '';
53         public $transport       = 'refresh';
54
55         /**
56          * Server-side sanitization callback for the setting's value.
57          *
58          * @var callback
59          */
60         public $sanitize_callback    = '';
61         public $sanitize_js_callback = '';
62
63         /**
64          * Whether or not the setting is initially dirty when created.
65          *
66          * This is used to ensure that a setting will be sent from the pane to the
67          * preview when loading the Customizer. Normally a setting only is synced to
68          * the preview if it has been changed. This allows the setting to be sent
69          * from the start.
70          *
71          * @since 4.2.0
72          * @access public
73          * @var bool
74          */
75         public $dirty = false;
76
77         protected $id_data = array();
78
79         /**
80          * Constructor.
81          *
82          * Any supplied $args override class property defaults.
83          *
84          * @since 3.4.0
85          *
86          * @param WP_Customize_Manager $manager
87          * @param string               $id      An specific ID of the setting. Can be a
88          *                                      theme mod or option name.
89          * @param array                $args    Setting arguments.
90          */
91         public function __construct( $manager, $id, $args = array() ) {
92                 $keys = array_keys( get_object_vars( $this ) );
93                 foreach ( $keys as $key ) {
94                         if ( isset( $args[ $key ] ) )
95                                 $this->$key = $args[ $key ];
96                 }
97
98                 $this->manager = $manager;
99                 $this->id = $id;
100
101                 // Parse the ID for array keys.
102                 $this->id_data[ 'keys' ] = preg_split( '/\[/', str_replace( ']', '', $this->id ) );
103                 $this->id_data[ 'base' ] = array_shift( $this->id_data[ 'keys' ] );
104
105                 // Rebuild the ID.
106                 $this->id = $this->id_data[ 'base' ];
107                 if ( ! empty( $this->id_data[ 'keys' ] ) )
108                         $this->id .= '[' . implode( '][', $this->id_data[ 'keys' ] ) . ']';
109
110                 if ( $this->sanitize_callback )
111                         add_filter( "customize_sanitize_{$this->id}", $this->sanitize_callback, 10, 2 );
112
113                 if ( $this->sanitize_js_callback )
114                         add_filter( "customize_sanitize_js_{$this->id}", $this->sanitize_js_callback, 10, 2 );
115         }
116
117         /**
118          * The ID for the current blog when the preview() method was called.
119          *
120          * @since 4.2.0
121          * @access protected
122          * @var int
123          */
124         protected $_previewed_blog_id;
125
126         /**
127          * Return true if the current blog is not the same as the previewed blog.
128          *
129          * @since 4.2.0
130          * @access public
131          *
132          * @return bool If preview() has been called.
133          */
134         public function is_current_blog_previewed() {
135                 if ( ! isset( $this->_previewed_blog_id ) ) {
136                         return false;
137                 }
138                 return ( get_current_blog_id() === $this->_previewed_blog_id );
139         }
140
141         /**
142          * Original non-previewed value stored by the preview method.
143          *
144          * @see WP_Customize_Setting::preview()
145          * @since 4.1.1
146          * @var mixed
147          */
148         protected $_original_value;
149
150         /**
151          * Handle previewing the setting.
152          *
153          * @since 3.4.0
154          */
155         public function preview() {
156                 if ( ! isset( $this->_original_value ) ) {
157                         $this->_original_value = $this->value();
158                 }
159                 if ( ! isset( $this->_previewed_blog_id ) ) {
160                         $this->_previewed_blog_id = get_current_blog_id();
161                 }
162
163                 switch( $this->type ) {
164                         case 'theme_mod' :
165                                 add_filter( 'theme_mod_' . $this->id_data[ 'base' ], array( $this, '_preview_filter' ) );
166                                 break;
167                         case 'option' :
168                                 if ( empty( $this->id_data[ 'keys' ] ) )
169                                         add_filter( 'pre_option_' . $this->id_data[ 'base' ], array( $this, '_preview_filter' ) );
170                                 else {
171                                         add_filter( 'option_' . $this->id_data[ 'base' ], array( $this, '_preview_filter' ) );
172                                         add_filter( 'default_option_' . $this->id_data[ 'base' ], array( $this, '_preview_filter' ) );
173                                 }
174                                 break;
175                         default :
176
177                                 /**
178                                  * Fires when the {@see WP_Customize_Setting::preview()} method is called for settings
179                                  * not handled as theme_mods or options.
180                                  *
181                                  * The dynamic portion of the hook name, `$this->id`, refers to the setting ID.
182                                  *
183                                  * @since 3.4.0
184                                  *
185                                  * @param WP_Customize_Setting $this {@see WP_Customize_Setting} instance.
186                                  */
187                                 do_action( "customize_preview_{$this->id}", $this );
188
189                                 /**
190                                  * Fires when the {@see WP_Customize_Setting::preview()} method is called for settings
191                                  * not handled as theme_mods or options.
192                                  *
193                                  * The dynamic portion of the hook name, `$this->type`, refers to the setting type.
194                                  *
195                                  * @since 4.1.0
196                                  *
197                                  * @param WP_Customize_Setting $this {@see WP_Customize_Setting} instance.
198                                  */
199                                 do_action( "customize_preview_{$this->type}", $this );
200                 }
201         }
202
203         /**
204          * Callback function to filter the theme mods and options.
205          *
206          * If switch_to_blog() was called after the preview() method, and the current
207          * blog is now not the same blog, then this method does a no-op and returns
208          * the original value.
209          *
210          * @since 3.4.0
211          * @uses WP_Customize_Setting::multidimensional_replace()
212          *
213          * @param mixed $original Old value.
214          * @return mixed New or old value.
215          */
216         public function _preview_filter( $original ) {
217                 if ( ! $this->is_current_blog_previewed() ) {
218                         return $original;
219                 }
220
221                 $undefined = new stdClass(); // symbol hack
222                 $post_value = $this->post_value( $undefined );
223                 if ( $undefined === $post_value ) {
224                         $value = $this->_original_value;
225                 } else {
226                         $value = $post_value;
227                 }
228
229                 return $this->multidimensional_replace( $original, $this->id_data['keys'], $value );
230         }
231
232         /**
233          * Check user capabilities and theme supports, and then save
234          * the value of the setting.
235          *
236          * @since 3.4.0
237          *
238          * @return false|void False if cap check fails or value isn't set.
239          */
240         final public function save() {
241                 $value = $this->post_value();
242
243                 if ( ! $this->check_capabilities() || ! isset( $value ) )
244                         return false;
245
246                 /**
247                  * Fires when the WP_Customize_Setting::save() method is called.
248                  *
249                  * The dynamic portion of the hook name, `$this->id_data['base']` refers to
250                  * the base slug of the setting name.
251                  *
252                  * @since 3.4.0
253                  *
254                  * @param WP_Customize_Setting $this {@see WP_Customize_Setting} instance.
255                  */
256                 do_action( 'customize_save_' . $this->id_data[ 'base' ], $this );
257
258                 $this->update( $value );
259         }
260
261         /**
262          * Fetch and sanitize the $_POST value for the setting.
263          *
264          * @since 3.4.0
265          *
266          * @param mixed $default A default value which is used as a fallback. Default is null.
267          * @return mixed The default value on failure, otherwise the sanitized value.
268          */
269         final public function post_value( $default = null ) {
270                 return $this->manager->post_value( $this, $default );
271         }
272
273         /**
274          * Sanitize an input.
275          *
276          * @since 3.4.0
277          *
278          * @param string|array $value The value to sanitize.
279          * @return string|array|null Null if an input isn't valid, otherwise the sanitized value.
280          */
281         public function sanitize( $value ) {
282                 $value = wp_unslash( $value );
283
284                 /**
285                  * Filter a Customize setting value in un-slashed form.
286                  *
287                  * @since 3.4.0
288                  *
289                  * @param mixed                $value Value of the setting.
290                  * @param WP_Customize_Setting $this  WP_Customize_Setting instance.
291                  */
292                 return apply_filters( "customize_sanitize_{$this->id}", $value, $this );
293         }
294
295         /**
296          * Save the value of the setting, using the related API.
297          *
298          * @since 3.4.0
299          *
300          * @param mixed $value The value to update.
301          * @return mixed The result of saving the value.
302          */
303         protected function update( $value ) {
304                 switch( $this->type ) {
305                         case 'theme_mod' :
306                                 return $this->_update_theme_mod( $value );
307
308                         case 'option' :
309                                 return $this->_update_option( $value );
310
311                         default :
312
313                                 /**
314                                  * Fires when the {@see WP_Customize_Setting::update()} method is called for settings
315                                  * not handled as theme_mods or options.
316                                  *
317                                  * The dynamic portion of the hook name, `$this->type`, refers to the type of setting.
318                                  *
319                                  * @since 3.4.0
320                                  *
321                                  * @param mixed                $value Value of the setting.
322                                  * @param WP_Customize_Setting $this  WP_Customize_Setting instance.
323                                  */
324                                 return do_action( 'customize_update_' . $this->type, $value, $this );
325                 }
326         }
327
328         /**
329          * Update the theme mod from the value of the parameter.
330          *
331          * @since 3.4.0
332          *
333          * @param mixed $value The value to update.
334          */
335         protected function _update_theme_mod( $value ) {
336                 // Handle non-array theme mod.
337                 if ( empty( $this->id_data[ 'keys' ] ) ) {
338                         set_theme_mod( $this->id_data[ 'base' ], $value );
339                         return;
340                 }
341                 // Handle array-based theme mod.
342                 $mods = get_theme_mod( $this->id_data[ 'base' ] );
343                 $mods = $this->multidimensional_replace( $mods, $this->id_data[ 'keys' ], $value );
344                 if ( isset( $mods ) ) {
345                         set_theme_mod( $this->id_data[ 'base' ], $mods );
346                 }
347         }
348
349         /**
350          * Update the option from the value of the setting.
351          *
352          * @since 3.4.0
353          *
354          * @param mixed $value The value to update.
355          * @return bool The result of saving the value.
356          */
357         protected function _update_option( $value ) {
358                 // Handle non-array option.
359                 if ( empty( $this->id_data[ 'keys' ] ) )
360                         return update_option( $this->id_data[ 'base' ], $value );
361
362                 // Handle array-based options.
363                 $options = get_option( $this->id_data[ 'base' ] );
364                 $options = $this->multidimensional_replace( $options, $this->id_data[ 'keys' ], $value );
365                 if ( isset( $options ) )
366                         return update_option( $this->id_data[ 'base' ], $options );
367         }
368
369         /**
370          * Fetch the value of the setting.
371          *
372          * @since 3.4.0
373          *
374          * @return mixed The value.
375          */
376         public function value() {
377                 // Get the callback that corresponds to the setting type.
378                 switch( $this->type ) {
379                         case 'theme_mod' :
380                                 $function = 'get_theme_mod';
381                                 break;
382                         case 'option' :
383                                 $function = 'get_option';
384                                 break;
385                         default :
386
387                                 /**
388                                  * Filter a Customize setting value not handled as a theme_mod or option.
389                                  *
390                                  * The dynamic portion of the hook name, `$this->id_date['base']`, refers to
391                                  * the base slug of the setting name.
392                                  *
393                                  * For settings handled as theme_mods or options, see those corresponding
394                                  * functions for available hooks.
395                                  *
396                                  * @since 3.4.0
397                                  *
398                                  * @param mixed $default The setting default value. Default empty.
399                                  */
400                                 return apply_filters( 'customize_value_' . $this->id_data[ 'base' ], $this->default );
401                 }
402
403                 // Handle non-array value
404                 if ( empty( $this->id_data[ 'keys' ] ) )
405                         return $function( $this->id_data[ 'base' ], $this->default );
406
407                 // Handle array-based value
408                 $values = $function( $this->id_data[ 'base' ] );
409                 return $this->multidimensional_get( $values, $this->id_data[ 'keys' ], $this->default );
410         }
411
412         /**
413          * Sanitize the setting's value for use in JavaScript.
414          *
415          * @since 3.4.0
416          *
417          * @return mixed The requested escaped value.
418          */
419         public function js_value() {
420
421                 /**
422                  * Filter a Customize setting value for use in JavaScript.
423                  *
424                  * The dynamic portion of the hook name, `$this->id`, refers to the setting ID.
425                  *
426                  * @since 3.4.0
427                  *
428                  * @param mixed                $value The setting value.
429                  * @param WP_Customize_Setting $this  {@see WP_Customize_Setting} instance.
430                  */
431                 $value = apply_filters( "customize_sanitize_js_{$this->id}", $this->value(), $this );
432
433                 if ( is_string( $value ) )
434                         return html_entity_decode( $value, ENT_QUOTES, 'UTF-8');
435
436                 return $value;
437         }
438
439         /**
440          * Validate user capabilities whether the theme supports the setting.
441          *
442          * @since 3.4.0
443          *
444          * @return bool False if theme doesn't support the setting or user can't change setting, otherwise true.
445          */
446         final public function check_capabilities() {
447                 if ( $this->capability && ! call_user_func_array( 'current_user_can', (array) $this->capability ) )
448                         return false;
449
450                 if ( $this->theme_supports && ! call_user_func_array( 'current_theme_supports', (array) $this->theme_supports ) )
451                         return false;
452
453                 return true;
454         }
455
456         /**
457          * Multidimensional helper function.
458          *
459          * @since 3.4.0
460          *
461          * @param $root
462          * @param $keys
463          * @param bool $create Default is false.
464          * @return array|void Keys are 'root', 'node', and 'key'.
465          */
466         final protected function multidimensional( &$root, $keys, $create = false ) {
467                 if ( $create && empty( $root ) )
468                         $root = array();
469
470                 if ( ! isset( $root ) || empty( $keys ) )
471                         return;
472
473                 $last = array_pop( $keys );
474                 $node = &$root;
475
476                 foreach ( $keys as $key ) {
477                         if ( $create && ! isset( $node[ $key ] ) )
478                                 $node[ $key ] = array();
479
480                         if ( ! is_array( $node ) || ! isset( $node[ $key ] ) )
481                                 return;
482
483                         $node = &$node[ $key ];
484                 }
485
486                 if ( $create ) {
487                         if ( ! is_array( $node ) ) {
488                                 // account for an array overriding a string or object value
489                                 $node = array();
490                         }
491                         if ( ! isset( $node[ $last ] ) ) {
492                                 $node[ $last ] = array();
493                         }
494                 }
495
496                 if ( ! isset( $node[ $last ] ) )
497                         return;
498
499                 return array(
500                         'root' => &$root,
501                         'node' => &$node,
502                         'key'  => $last,
503                 );
504         }
505
506         /**
507          * Will attempt to replace a specific value in a multidimensional array.
508          *
509          * @since 3.4.0
510          *
511          * @param $root
512          * @param $keys
513          * @param mixed $value The value to update.
514          * @return
515          */
516         final protected function multidimensional_replace( $root, $keys, $value ) {
517                 if ( ! isset( $value ) )
518                         return $root;
519                 elseif ( empty( $keys ) ) // If there are no keys, we're replacing the root.
520                         return $value;
521
522                 $result = $this->multidimensional( $root, $keys, true );
523
524                 if ( isset( $result ) )
525                         $result['node'][ $result['key'] ] = $value;
526
527                 return $root;
528         }
529
530         /**
531          * Will attempt to fetch a specific value from a multidimensional array.
532          *
533          * @since 3.4.0
534          *
535          * @param $root
536          * @param $keys
537          * @param mixed $default A default value which is used as a fallback. Default is null.
538          * @return mixed The requested value or the default value.
539          */
540         final protected function multidimensional_get( $root, $keys, $default = null ) {
541                 if ( empty( $keys ) ) // If there are no keys, test the root.
542                         return isset( $root ) ? $root : $default;
543
544                 $result = $this->multidimensional( $root, $keys );
545                 return isset( $result ) ? $result['node'][ $result['key'] ] : $default;
546         }
547
548         /**
549          * Will attempt to check if a specific value in a multidimensional array is set.
550          *
551          * @since 3.4.0
552          *
553          * @param $root
554          * @param $keys
555          * @return bool True if value is set, false if not.
556          */
557         final protected function multidimensional_isset( $root, $keys ) {
558                 $result = $this->multidimensional_get( $root, $keys );
559                 return isset( $result );
560         }
561 }
562
563 /**
564  * A setting that is used to filter a value, but will not save the results.
565  *
566  * Results should be properly handled using another setting or callback.
567  *
568  * @since 3.4.0
569  *
570  * @see WP_Customize_Setting
571  */
572 class WP_Customize_Filter_Setting extends WP_Customize_Setting {
573
574         /**
575          * @since 3.4.0
576          */
577         public function update( $value ) {}
578 }
579
580 /**
581  * A setting that is used to filter a value, but will not save the results.
582  *
583  * Results should be properly handled using another setting or callback.
584  *
585  * @since 3.4.0
586  *
587  * @see WP_Customize_Setting
588  */
589 final class WP_Customize_Header_Image_Setting extends WP_Customize_Setting {
590         public $id = 'header_image_data';
591
592         /**
593          * @since 3.4.0
594          *
595          * @global Custom_Image_Header $custom_image_header
596          *
597          * @param $value
598          */
599         public function update( $value ) {
600                 global $custom_image_header;
601
602                 // If the value doesn't exist (removed or random),
603                 // use the header_image value.
604                 if ( ! $value )
605                         $value = $this->manager->get_setting('header_image')->post_value();
606
607                 if ( is_array( $value ) && isset( $value['choice'] ) )
608                         $custom_image_header->set_header_image( $value['choice'] );
609                 else
610                         $custom_image_header->set_header_image( $value );
611         }
612 }
613
614 /**
615  * Customizer Background Image Setting class.
616  *
617  * @since 3.4.0
618  *
619  * @see WP_Customize_Setting
620  */
621 final class WP_Customize_Background_Image_Setting extends WP_Customize_Setting {
622         public $id = 'background_image_thumb';
623
624         /**
625          * @since 3.4.0
626          *
627          * @param $value
628          */
629         public function update( $value ) {
630                 remove_theme_mod( 'background_image_thumb' );
631         }
632 }
633
634 /**
635  * Customize Setting to represent a nav_menu.
636  *
637  * Subclass of WP_Customize_Setting to represent a nav_menu taxonomy term, and
638  * the IDs for the nav_menu_items associated with the nav menu.
639  *
640  * @since 4.3.0
641  *
642  * @see WP_Customize_Setting
643  */
644 class WP_Customize_Nav_Menu_Item_Setting extends WP_Customize_Setting {
645
646         const ID_PATTERN = '/^nav_menu_item\[(?P<id>-?\d+)\]$/';
647
648         const POST_TYPE = 'nav_menu_item';
649
650         const TYPE = 'nav_menu_item';
651
652         /**
653          * Setting type.
654          *
655          * @since 4.3.0
656          * @access public
657          * @var string
658          */
659         public $type = self::TYPE;
660
661         /**
662          * Default setting value.
663          *
664          * @since 4.3.0
665          * @access public
666          * @var array
667          *
668          * @see wp_setup_nav_menu_item()
669          */
670         public $default = array(
671                 // The $menu_item_data for wp_update_nav_menu_item().
672                 'object_id'        => 0,
673                 'object'           => '', // Taxonomy name.
674                 'menu_item_parent' => 0, // A.K.A. menu-item-parent-id; note that post_parent is different, and not included.
675                 'position'         => 0, // A.K.A. menu_order.
676                 'type'             => 'custom', // Note that type_label is not included here.
677                 'title'            => '',
678                 'url'              => '',
679                 'target'           => '',
680                 'attr_title'       => '',
681                 'description'      => '',
682                 'classes'          => '',
683                 'xfn'              => '',
684                 'status'           => 'publish',
685                 'original_title'   => '',
686                 'nav_menu_term_id' => 0, // This will be supplied as the $menu_id arg for wp_update_nav_menu_item().
687                 // @todo also expose invalid?
688         );
689
690         /**
691          * Default transport.
692          *
693          * @since 4.3.0
694          * @access public
695          * @var string
696          */
697         public $transport = 'postMessage';
698
699         /**
700          * The post ID represented by this setting instance. This is the db_id.
701          *
702          * A negative value represents a placeholder ID for a new menu not yet saved.
703          *
704          * @since 4.3.0
705          * @access public
706          * @var int
707          */
708         public $post_id;
709
710         /**
711          * Storage of pre-setup menu item to prevent wasted calls to wp_setup_nav_menu_item().
712          *
713          * @since 4.3.0
714          * @access protected
715          * @var array
716          */
717         protected $value;
718
719         /**
720          * Previous (placeholder) post ID used before creating a new menu item.
721          *
722          * This value will be exported to JS via the customize_save_response filter
723          * so that JavaScript can update the settings to refer to the newly-assigned
724          * post ID. This value is always negative to indicate it does not refer to
725          * a real post.
726          *
727          * @since 4.3.0
728          * @access public
729          * @var int
730          *
731          * @see WP_Customize_Nav_Menu_Item_Setting::update()
732          * @see WP_Customize_Nav_Menu_Item_Setting::amend_customize_save_response()
733          */
734         public $previous_post_id;
735
736         /**
737          * When previewing or updating a menu item, this stores the previous nav_menu_term_id
738          * which ensures that we can apply the proper filters.
739          *
740          * @since 4.3.0
741          * @access public
742          * @var int
743          */
744         public $original_nav_menu_term_id;
745
746         /**
747          * Whether or not preview() was called.
748          *
749          * @since 4.3.0
750          * @access protected
751          * @var bool
752          */
753         protected $is_previewed = false;
754
755         /**
756          * Whether or not update() was called.
757          *
758          * @since 4.3.0
759          * @access protected
760          * @var bool
761          */
762         protected $is_updated = false;
763
764         /**
765          * Status for calling the update method, used in customize_save_response filter.
766          *
767          * When status is inserted, the placeholder post ID is stored in $previous_post_id.
768          * When status is error, the error is stored in $update_error.
769          *
770          * @since 4.3.0
771          * @access public
772          * @var string updated|inserted|deleted|error
773          *
774          * @see WP_Customize_Nav_Menu_Item_Setting::update()
775          * @see WP_Customize_Nav_Menu_Item_Setting::amend_customize_save_response()
776          */
777         public $update_status;
778
779         /**
780          * Any error object returned by wp_update_nav_menu_item() when setting is updated.
781          *
782          * @since 4.3.0
783          * @access public
784          * @var WP_Error
785          *
786          * @see WP_Customize_Nav_Menu_Item_Setting::update()
787          * @see WP_Customize_Nav_Menu_Item_Setting::amend_customize_save_response()
788          */
789         public $update_error;
790
791         /**
792          * Constructor.
793          *
794          * Any supplied $args override class property defaults.
795          *
796          * @since 4.3.0
797          * @access public
798          *
799          * @param WP_Customize_Manager $manager Bootstrap Customizer instance.
800          * @param string               $id      An specific ID of the setting. Can be a
801          *                                      theme mod or option name.
802          * @param array                $args    Optional. Setting arguments.
803          *
804          * @throws Exception If $id is not valid for this setting type.
805          */
806         public function __construct( WP_Customize_Manager $manager, $id, array $args = array() ) {
807                 if ( empty( $manager->nav_menus ) ) {
808                         throw new Exception( 'Expected WP_Customize_Manager::$nav_menus to be set.' );
809                 }
810
811                 if ( ! preg_match( self::ID_PATTERN, $id, $matches ) ) {
812                         throw new Exception( "Illegal widget setting ID: $id" );
813                 }
814
815                 $this->post_id = intval( $matches['id'] );
816                 add_action( 'wp_update_nav_menu_item', array( $this, 'flush_cached_value' ), 10, 2 );
817
818                 parent::__construct( $manager, $id, $args );
819
820                 // Ensure that an initially-supplied value is valid.
821                 if ( isset( $this->value ) ) {
822                         $this->populate_value();
823                         foreach ( array_diff( array_keys( $this->default ), array_keys( $this->value ) ) as $missing ) {
824                                 throw new Exception( "Supplied nav_menu_item value missing property: $missing" );
825                         }
826                 }
827
828         }
829
830         /**
831          * Clear the cached value when this nav menu item is updated.
832          *
833          * @since 4.3.0
834          * @access public
835          *
836          * @param int $menu_id       The term ID for the menu.
837          * @param int $menu_item_id  The post ID for the menu item.
838          */
839         public function flush_cached_value( $menu_id, $menu_item_id ) {
840                 unset( $menu_id );
841                 if ( $menu_item_id === $this->post_id ) {
842                         $this->value = null;
843                 }
844         }
845
846         /**
847          * Get the instance data for a given widget setting.
848          *
849          * @since 4.3.0
850          * @access public
851          *
852          * @see wp_setup_nav_menu_item()
853          *
854          * @return array|false Instance data array, or false if the item is marked for deletion.
855          */
856         public function value() {
857                 if ( $this->is_previewed && $this->_previewed_blog_id === get_current_blog_id() ) {
858                         $undefined  = new stdClass(); // Symbol.
859                         $post_value = $this->post_value( $undefined );
860
861                         if ( $undefined === $post_value ) {
862                                 $value = $this->_original_value;
863                         } else {
864                                 $value = $post_value;
865                         }
866                 } else if ( isset( $this->value ) ) {
867                         $value = $this->value;
868                 } else {
869                         $value = false;
870
871                         // Note that a ID of less than one indicates a nav_menu not yet inserted.
872                         if ( $this->post_id > 0 ) {
873                                 $post = get_post( $this->post_id );
874                                 if ( $post && self::POST_TYPE === $post->post_type ) {
875                                         $value = (array) wp_setup_nav_menu_item( $post );
876                                 }
877                         }
878
879                         if ( ! is_array( $value ) ) {
880                                 $value = $this->default;
881                         }
882
883                         // Cache the value for future calls to avoid having to re-call wp_setup_nav_menu_item().
884                         $this->value = $value;
885                         $this->populate_value();
886                         $value = $this->value;
887                 }
888
889                 return $value;
890         }
891
892         /**
893          * Ensure that the value is fully populated with the necessary properties.
894          *
895          * Translates some properties added by wp_setup_nav_menu_item() and removes others.
896          *
897          * @since 4.3.0
898          * @access protected
899          *
900          * @see WP_Customize_Nav_Menu_Item_Setting::value()
901          */
902         protected function populate_value() {
903                 if ( ! is_array( $this->value ) ) {
904                         return;
905                 }
906
907                 if ( isset( $this->value['menu_order'] ) ) {
908                         $this->value['position'] = $this->value['menu_order'];
909                         unset( $this->value['menu_order'] );
910                 }
911                 if ( isset( $this->value['post_status'] ) ) {
912                         $this->value['status'] = $this->value['post_status'];
913                         unset( $this->value['post_status'] );
914                 }
915
916                 if ( ! isset( $this->value['original_title'] ) ) {
917                         $original_title = '';
918                         if ( 'post_type' === $this->value['type'] ) {
919                                 $original_title = get_the_title( $this->value['object_id'] );
920                         } elseif ( 'taxonomy' === $this->value['type'] ) {
921                                 $original_title = get_term_field( 'name', $this->value['object_id'], $this->value['object'], 'raw' );
922                                 if ( is_wp_error( $original_title ) ) {
923                                         $original_title = '';
924                                 }
925                         }
926                         $this->value['original_title'] = html_entity_decode( $original_title, ENT_QUOTES, get_bloginfo( 'charset' ) );
927                 }
928
929                 if ( ! isset( $this->value['nav_menu_term_id'] ) && $this->post_id > 0 ) {
930                         $menus = wp_get_post_terms( $this->post_id, WP_Customize_Nav_Menu_Setting::TAXONOMY, array(
931                                 'fields' => 'ids',
932                         ) );
933                         if ( ! empty( $menus ) ) {
934                                 $this->value['nav_menu_term_id'] = array_shift( $menus );
935                         } else {
936                                 $this->value['nav_menu_term_id'] = 0;
937                         }
938                 }
939
940                 foreach ( array( 'object_id', 'menu_item_parent', 'nav_menu_term_id' ) as $key ) {
941                         if ( ! is_int( $this->value[ $key ] ) ) {
942                                 $this->value[ $key ] = intval( $this->value[ $key ] );
943                         }
944                 }
945
946                 // Remove remaining properties available on a setup nav_menu_item post object which aren't relevant to the setting value.
947                 $irrelevant_properties = array(
948                         'ID',
949                         'comment_count',
950                         'comment_status',
951                         'db_id',
952                         'filter',
953                         'guid',
954                         'ping_status',
955                         'pinged',
956                         'post_author',
957                         'post_content',
958                         'post_content_filtered',
959                         'post_date',
960                         'post_date_gmt',
961                         'post_excerpt',
962                         'post_mime_type',
963                         'post_modified',
964                         'post_modified_gmt',
965                         'post_name',
966                         'post_parent',
967                         'post_password',
968                         'post_title',
969                         'post_type',
970                         'to_ping',
971                 );
972                 foreach ( $irrelevant_properties as $property ) {
973                         unset( $this->value[ $property ] );
974                 }
975         }
976
977         /**
978          * Handle previewing the setting.
979          *
980          * @since 4.3.0
981          * @access public
982          *
983          * @see WP_Customize_Manager::post_value()
984          */
985         public function preview() {
986                 if ( $this->is_previewed ) {
987                         return;
988                 }
989
990                 $this->is_previewed              = true;
991                 $this->_original_value           = $this->value();
992                 $this->original_nav_menu_term_id = $this->_original_value['nav_menu_term_id'];
993                 $this->_previewed_blog_id        = get_current_blog_id();
994
995                 add_filter( 'wp_get_nav_menu_items', array( $this, 'filter_wp_get_nav_menu_items' ), 10, 3 );
996
997                 $sort_callback = array( __CLASS__, 'sort_wp_get_nav_menu_items' );
998                 if ( ! has_filter( 'wp_get_nav_menu_items', $sort_callback ) ) {
999                         add_filter( 'wp_get_nav_menu_items', array( __CLASS__, 'sort_wp_get_nav_menu_items' ), 1000, 3 );
1000                 }
1001
1002                 // @todo Add get_post_metadata filters for plugins to add their data.
1003         }
1004
1005         /**
1006          * Filter the wp_get_nav_menu_items() result to supply the previewed menu items.
1007          *
1008          * @since 4.3.0
1009          * @access public
1010          *
1011          * @see wp_get_nav_menu_items()
1012          *
1013          * @param array  $items An array of menu item post objects.
1014          * @param object $menu  The menu object.
1015          * @param array  $args  An array of arguments used to retrieve menu item objects.
1016          * @return array Array of menu items,
1017          */
1018         public function filter_wp_get_nav_menu_items( $items, $menu, $args ) {
1019                 $this_item = $this->value();
1020                 $current_nav_menu_term_id = $this_item['nav_menu_term_id'];
1021                 unset( $this_item['nav_menu_term_id'] );
1022
1023                 $should_filter = (
1024                         $menu->term_id === $this->original_nav_menu_term_id
1025                         ||
1026                         $menu->term_id === $current_nav_menu_term_id
1027                 );
1028                 if ( ! $should_filter ) {
1029                         return $items;
1030                 }
1031
1032                 // Handle deleted menu item, or menu item moved to another menu.
1033                 $should_remove = (
1034                         false === $this_item
1035                         ||
1036                         (
1037                                 $this->original_nav_menu_term_id === $menu->term_id
1038                                 &&
1039                                 $current_nav_menu_term_id !== $this->original_nav_menu_term_id
1040                         )
1041                 );
1042                 if ( $should_remove ) {
1043                         $filtered_items = array();
1044                         foreach ( $items as $item ) {
1045                                 if ( $item->db_id !== $this->post_id ) {
1046                                         $filtered_items[] = $item;
1047                                 }
1048                         }
1049                         return $filtered_items;
1050                 }
1051
1052                 $mutated = false;
1053                 $should_update = (
1054                         is_array( $this_item )
1055                         &&
1056                         $current_nav_menu_term_id === $menu->term_id
1057                 );
1058                 if ( $should_update ) {
1059                         foreach ( $items as $item ) {
1060                                 if ( $item->db_id === $this->post_id ) {
1061                                         foreach ( get_object_vars( $this->value_as_wp_post_nav_menu_item() ) as $key => $value ) {
1062                                                 $item->$key = $value;
1063                                         }
1064                                         $mutated = true;
1065                                 }
1066                         }
1067
1068                         // Not found so we have to append it..
1069                         if ( ! $mutated ) {
1070                                 $items[] = $this->value_as_wp_post_nav_menu_item();
1071                         }
1072                 }
1073
1074                 return $items;
1075         }
1076
1077         /**
1078          * Re-apply the tail logic also applied on $items by wp_get_nav_menu_items().
1079          *
1080          * @since 4.3.0
1081          * @access public
1082          * @static
1083          *
1084          * @see wp_get_nav_menu_items()
1085          *
1086          * @param array  $items An array of menu item post objects.
1087          * @param object $menu  The menu object.
1088          * @param array  $args  An array of arguments used to retrieve menu item objects.
1089          * @return array Array of menu items,
1090          */
1091         public static function sort_wp_get_nav_menu_items( $items, $menu, $args ) {
1092                 // @todo We should probably re-apply some constraints imposed by $args.
1093                 unset( $args['include'] );
1094
1095                 // Remove invalid items only in frontend.
1096                 if ( ! is_admin() ) {
1097                         $items = array_filter( $items, '_is_valid_nav_menu_item' );
1098                 }
1099
1100                 if ( ARRAY_A === $args['output'] ) {
1101                         $GLOBALS['_menu_item_sort_prop'] = $args['output_key'];
1102                         usort( $items, '_sort_nav_menu_items' );
1103                         $i = 1;
1104
1105                         foreach ( $items as $k => $item ) {
1106                                 $items[ $k ]->{$args['output_key']} = $i++;
1107                         }
1108                 }
1109
1110                 return $items;
1111         }
1112
1113         /**
1114          * Get the value emulated into a WP_Post and set up as a nav_menu_item.
1115          *
1116          * @since 4.3.0
1117          * @access public
1118          *
1119          * @return WP_Post With wp_setup_nav_menu_item() applied.
1120          */
1121         public function value_as_wp_post_nav_menu_item() {
1122                 $item = (object) $this->value();
1123                 unset( $item->nav_menu_term_id );
1124
1125                 $item->post_status = $item->status;
1126                 unset( $item->status );
1127
1128                 $item->post_type = 'nav_menu_item';
1129                 $item->menu_order = $item->position;
1130                 unset( $item->position );
1131
1132                 if ( $item->title ) {
1133                         $item->post_title = $item->title;
1134                 }
1135
1136                 $item->ID = $this->post_id;
1137                 $item->db_id = $this->post_id;
1138                 $post = new WP_Post( (object) $item );
1139
1140                 if ( empty( $post->post_author ) ) {
1141                         $post->post_author = get_current_user_id();
1142                 }
1143
1144                 if ( ! isset( $post->type_label ) ) {
1145                         if ( 'post_type' === $post->type ) {
1146                                 $object = get_post_type_object( $post->object );
1147                                 if ( $object ) {
1148                                         $post->type_label = $object->labels->singular_name;
1149                                 } else {
1150                                         $post->type_label = $post->object;
1151                                 }
1152                         } elseif ( 'taxonomy' == $post->type ) {
1153                                 $object = get_taxonomy( $post->object );
1154                                 if ( $object ) {
1155                                         $post->type_label = $object->labels->singular_name;
1156                                 } else {
1157                                         $post->type_label = $post->object;
1158                                 }
1159                         } else {
1160                                 $post->type_label = __( 'Custom Link' );
1161                         }
1162                 }
1163
1164                 return $post;
1165         }
1166
1167         /**
1168          * Sanitize an input.
1169          *
1170          * Note that parent::sanitize() erroneously does wp_unslash() on $value, but
1171          * we remove that in this override.
1172          *
1173          * @since 4.3.0
1174          * @access public
1175          *
1176          * @param array $menu_item_value The value to sanitize.
1177          * @return array|false|null Null if an input isn't valid. False if it is marked for deletion.
1178          *                          Otherwise the sanitized value.
1179          */
1180         public function sanitize( $menu_item_value ) {
1181                 // Menu is marked for deletion.
1182                 if ( false === $menu_item_value ) {
1183                         return $menu_item_value;
1184                 }
1185
1186                 // Invalid.
1187                 if ( ! is_array( $menu_item_value ) ) {
1188                         return null;
1189                 }
1190
1191                 $default = array(
1192                         'object_id'        => 0,
1193                         'object'           => '',
1194                         'menu_item_parent' => 0,
1195                         'position'         => 0,
1196                         'type'             => 'custom',
1197                         'title'            => '',
1198                         'url'              => '',
1199                         'target'           => '',
1200                         'attr_title'       => '',
1201                         'description'      => '',
1202                         'classes'          => '',
1203                         'xfn'              => '',
1204                         'status'           => 'publish',
1205                         'original_title'   => '',
1206                         'nav_menu_term_id' => 0,
1207                 );
1208                 $menu_item_value = array_merge( $default, $menu_item_value );
1209                 $menu_item_value = wp_array_slice_assoc( $menu_item_value, array_keys( $default ) );
1210                 $menu_item_value['position'] = max( 0, intval( $menu_item_value['position'] ) );
1211
1212                 foreach ( array( 'object_id', 'menu_item_parent', 'nav_menu_term_id' ) as $key ) {
1213                         // Note we need to allow negative-integer IDs for previewed objects not inserted yet.
1214                         $menu_item_value[ $key ] = intval( $menu_item_value[ $key ] );
1215                 }
1216
1217                 foreach ( array( 'type', 'object', 'target' ) as $key ) {
1218                         $menu_item_value[ $key ] = sanitize_key( $menu_item_value[ $key ] );
1219                 }
1220
1221                 foreach ( array( 'xfn', 'classes' ) as $key ) {
1222                         $value = $menu_item_value[ $key ];
1223                         if ( ! is_array( $value ) ) {
1224                                 $value = explode( ' ', $value );
1225                         }
1226                         $menu_item_value[ $key ] = implode( ' ', array_map( 'sanitize_html_class', $value ) );
1227                 }
1228
1229                 foreach ( array( 'title', 'attr_title', 'description', 'original_title' ) as $key ) {
1230                         // @todo Should esc_attr() the attr_title as well?
1231                         $menu_item_value[ $key ] = sanitize_text_field( $menu_item_value[ $key ] );
1232                 }
1233
1234                 $menu_item_value['url'] = esc_url_raw( $menu_item_value['url'] );
1235                 if ( ! get_post_status_object( $menu_item_value['status'] ) ) {
1236                         $menu_item_value['status'] = 'publish';
1237                 }
1238
1239                 /** This filter is documented in wp-includes/class-wp-customize-setting.php */
1240                 return apply_filters( "customize_sanitize_{$this->id}", $menu_item_value, $this );
1241         }
1242
1243         /**
1244          * Create/update the nav_menu_item post for this setting.
1245          *
1246          * Any created menu items will have their assigned post IDs exported to the client
1247          * via the customize_save_response filter. Likewise, any errors will be exported
1248          * to the client via the customize_save_response() filter.
1249          *
1250          * To delete a menu, the client can send false as the value.
1251          *
1252          * @since 4.3.0
1253          * @access protected
1254          *
1255          * @see wp_update_nav_menu_item()
1256          *
1257          * @param array|false $value The menu item array to update. If false, then the menu item will be deleted
1258          *                           entirely. See WP_Customize_Nav_Menu_Item_Setting::$default for what the value
1259          *                           should consist of.
1260          * @return null|void
1261          */
1262         protected function update( $value ) {
1263                 if ( $this->is_updated ) {
1264                         return;
1265                 }
1266
1267                 $this->is_updated = true;
1268                 $is_placeholder   = ( $this->post_id < 0 );
1269                 $is_delete        = ( false === $value );
1270
1271                 // Update the cached value.
1272                 $this->value = $value;
1273
1274                 add_filter( 'customize_save_response', array( $this, 'amend_customize_save_response' ) );
1275
1276                 if ( $is_delete ) {
1277                         // If the current setting post is a placeholder, a delete request is a no-op.
1278                         if ( $is_placeholder ) {
1279                                 $this->update_status = 'deleted';
1280                         } else {
1281                                 $r = wp_delete_post( $this->post_id, true );
1282
1283                                 if ( false === $r ) {
1284                                         $this->update_error  = new WP_Error( 'delete_failure' );
1285                                         $this->update_status = 'error';
1286                                 } else {
1287                                         $this->update_status = 'deleted';
1288                                 }
1289                                 // @todo send back the IDs for all associated nav menu items deleted, so these settings (and controls) can be removed from Customizer?
1290                         }
1291                 } else {
1292
1293                         // Handle saving menu items for menus that are being newly-created.
1294                         if ( $value['nav_menu_term_id'] < 0 ) {
1295                                 $nav_menu_setting_id = sprintf( 'nav_menu[%s]', $value['nav_menu_term_id'] );
1296                                 $nav_menu_setting    = $this->manager->get_setting( $nav_menu_setting_id );
1297
1298                                 if ( ! $nav_menu_setting || ! ( $nav_menu_setting instanceof WP_Customize_Nav_Menu_Setting ) ) {
1299                                         $this->update_status = 'error';
1300                                         $this->update_error  = new WP_Error( 'unexpected_nav_menu_setting' );
1301                                         return;
1302                                 }
1303
1304                                 if ( false === $nav_menu_setting->save() ) {
1305                                         $this->update_status = 'error';
1306                                         $this->update_error  = new WP_Error( 'nav_menu_setting_failure' );
1307                                         return;
1308                                 }
1309
1310                                 if ( $nav_menu_setting->previous_term_id !== intval( $value['nav_menu_term_id'] ) ) {
1311                                         $this->update_status = 'error';
1312                                         $this->update_error  = new WP_Error( 'unexpected_previous_term_id' );
1313                                         return;
1314                                 }
1315
1316                                 $value['nav_menu_term_id'] = $nav_menu_setting->term_id;
1317                         }
1318
1319                         // Handle saving a nav menu item that is a child of a nav menu item being newly-created.
1320                         if ( $value['menu_item_parent'] < 0 ) {
1321                                 $parent_nav_menu_item_setting_id = sprintf( 'nav_menu_item[%s]', $value['menu_item_parent'] );
1322                                 $parent_nav_menu_item_setting    = $this->manager->get_setting( $parent_nav_menu_item_setting_id );
1323
1324                                 if ( ! $parent_nav_menu_item_setting || ! ( $parent_nav_menu_item_setting instanceof WP_Customize_Nav_Menu_Item_Setting ) ) {
1325                                         $this->update_status = 'error';
1326                                         $this->update_error  = new WP_Error( 'unexpected_nav_menu_item_setting' );
1327                                         return;
1328                                 }
1329
1330                                 if ( false === $parent_nav_menu_item_setting->save() ) {
1331                                         $this->update_status = 'error';
1332                                         $this->update_error  = new WP_Error( 'nav_menu_item_setting_failure' );
1333                                         return;
1334                                 }
1335
1336                                 if ( $parent_nav_menu_item_setting->previous_post_id !== intval( $value['menu_item_parent'] ) ) {
1337                                         $this->update_status = 'error';
1338                                         $this->update_error  = new WP_Error( 'unexpected_previous_post_id' );
1339                                         return;
1340                                 }
1341
1342                                 $value['menu_item_parent'] = $parent_nav_menu_item_setting->post_id;
1343                         }
1344
1345                         // Insert or update menu.
1346                         $menu_item_data = array(
1347                                 'menu-item-object-id'   => $value['object_id'],
1348                                 'menu-item-object'      => $value['object'],
1349                                 'menu-item-parent-id'   => $value['menu_item_parent'],
1350                                 'menu-item-position'    => $value['position'],
1351                                 'menu-item-type'        => $value['type'],
1352                                 'menu-item-title'       => $value['title'],
1353                                 'menu-item-url'         => $value['url'],
1354                                 'menu-item-description' => $value['description'],
1355                                 'menu-item-attr-title'  => $value['attr_title'],
1356                                 'menu-item-target'      => $value['target'],
1357                                 'menu-item-classes'     => $value['classes'],
1358                                 'menu-item-xfn'         => $value['xfn'],
1359                                 'menu-item-status'      => $value['status'],
1360                         );
1361
1362                         $r = wp_update_nav_menu_item(
1363                                 $value['nav_menu_term_id'],
1364                                 $is_placeholder ? 0 : $this->post_id,
1365                                 $menu_item_data
1366                         );
1367
1368                         if ( is_wp_error( $r ) ) {
1369                                 $this->update_status = 'error';
1370                                 $this->update_error = $r;
1371                         } else {
1372                                 if ( $is_placeholder ) {
1373                                         $this->previous_post_id = $this->post_id;
1374                                         $this->post_id = $r;
1375                                         $this->update_status = 'inserted';
1376                                 } else {
1377                                         $this->update_status = 'updated';
1378                                 }
1379                         }
1380                 }
1381
1382         }
1383
1384         /**
1385          * Export data for the JS client.
1386          *
1387          * @since 4.3.0
1388          * @access public
1389          *
1390          * @see WP_Customize_Nav_Menu_Item_Setting::update()
1391          *
1392          * @param array $data Additional information passed back to the 'saved' event on `wp.customize`.
1393          * @return array Save response data.
1394          */
1395         public function amend_customize_save_response( $data ) {
1396                 if ( ! isset( $data['nav_menu_item_updates'] ) ) {
1397                         $data['nav_menu_item_updates'] = array();
1398                 }
1399
1400                 $data['nav_menu_item_updates'][] = array(
1401                         'post_id'          => $this->post_id,
1402                         'previous_post_id' => $this->previous_post_id,
1403                         'error'            => $this->update_error ? $this->update_error->get_error_code() : null,
1404                         'status'           => $this->update_status,
1405                 );
1406                 return $data;
1407         }
1408 }
1409
1410 /**
1411  * Customize Setting to represent a nav_menu.
1412  *
1413  * Subclass of WP_Customize_Setting to represent a nav_menu taxonomy term, and
1414  * the IDs for the nav_menu_items associated with the nav menu.
1415  *
1416  * @since 4.3.0
1417  *
1418  * @see wp_get_nav_menu_object()
1419  * @see WP_Customize_Setting
1420  */
1421 class WP_Customize_Nav_Menu_Setting extends WP_Customize_Setting {
1422
1423         const ID_PATTERN = '/^nav_menu\[(?P<id>-?\d+)\]$/';
1424
1425         const TAXONOMY = 'nav_menu';
1426
1427         const TYPE = 'nav_menu';
1428
1429         /**
1430          * Setting type.
1431          *
1432          * @since 4.3.0
1433          * @access public
1434          * @var string
1435          */
1436         public $type = self::TYPE;
1437
1438         /**
1439          * Default setting value.
1440          *
1441          * @since 4.3.0
1442          * @access public
1443          * @var array
1444          *
1445          * @see wp_get_nav_menu_object()
1446          */
1447         public $default = array(
1448                 'name'        => '',
1449                 'description' => '',
1450                 'parent'      => 0,
1451                 'auto_add'    => false,
1452         );
1453
1454         /**
1455          * Default transport.
1456          *
1457          * @since 4.3.0
1458          * @access public
1459          * @var string
1460          */
1461         public $transport = 'postMessage';
1462
1463         /**
1464          * The term ID represented by this setting instance.
1465          *
1466          * A negative value represents a placeholder ID for a new menu not yet saved.
1467          *
1468          * @since 4.3.0
1469          * @access public
1470          * @var int
1471          */
1472         public $term_id;
1473
1474         /**
1475          * Previous (placeholder) term ID used before creating a new menu.
1476          *
1477          * This value will be exported to JS via the customize_save_response filter
1478          * so that JavaScript can update the settings to refer to the newly-assigned
1479          * term ID. This value is always negative to indicate it does not refer to
1480          * a real term.
1481          *
1482          * @since 4.3.0
1483          * @access public
1484          * @var int
1485          *
1486          * @see WP_Customize_Nav_Menu_Setting::update()
1487          * @see WP_Customize_Nav_Menu_Setting::amend_customize_save_response()
1488          */
1489         public $previous_term_id;
1490
1491         /**
1492          * Whether or not preview() was called.
1493          *
1494          * @since 4.3.0
1495          * @access protected
1496          * @var bool
1497          */
1498         protected $is_previewed = false;
1499
1500         /**
1501          * Whether or not update() was called.
1502          *
1503          * @since 4.3.0
1504          * @access protected
1505          * @var bool
1506          */
1507         protected $is_updated = false;
1508
1509         /**
1510          * Status for calling the update method, used in customize_save_response filter.
1511          *
1512          * When status is inserted, the placeholder term ID is stored in $previous_term_id.
1513          * When status is error, the error is stored in $update_error.
1514          *
1515          * @since 4.3.0
1516          * @access public
1517          * @var string updated|inserted|deleted|error
1518          *
1519          * @see WP_Customize_Nav_Menu_Setting::update()
1520          * @see WP_Customize_Nav_Menu_Setting::amend_customize_save_response()
1521          */
1522         public $update_status;
1523
1524         /**
1525          * Any error object returned by wp_update_nav_menu_object() when setting is updated.
1526          *
1527          * @since 4.3.0
1528          * @access public
1529          * @var WP_Error
1530          *
1531          * @see WP_Customize_Nav_Menu_Setting::update()
1532          * @see WP_Customize_Nav_Menu_Setting::amend_customize_save_response()
1533          */
1534         public $update_error;
1535
1536         /**
1537          * Constructor.
1538          *
1539          * Any supplied $args override class property defaults.
1540          *
1541          * @since 4.3.0
1542          * @access public
1543          *
1544          * @param WP_Customize_Manager $manager Bootstrap Customizer instance.
1545          * @param string               $id      An specific ID of the setting. Can be a
1546          *                                      theme mod or option name.
1547          * @param array                $args    Optional. Setting arguments.
1548          *
1549          * @throws Exception If $id is not valid for this setting type.
1550          */
1551         public function __construct( WP_Customize_Manager $manager, $id, array $args = array() ) {
1552                 if ( empty( $manager->nav_menus ) ) {
1553                         throw new Exception( 'Expected WP_Customize_Manager::$nav_menus to be set.' );
1554                 }
1555
1556                 if ( ! preg_match( self::ID_PATTERN, $id, $matches ) ) {
1557                         throw new Exception( "Illegal widget setting ID: $id" );
1558                 }
1559
1560                 $this->term_id = intval( $matches['id'] );
1561
1562                 parent::__construct( $manager, $id, $args );
1563         }
1564
1565         /**
1566          * Get the instance data for a given widget setting.
1567          *
1568          * @since 4.3.0
1569          * @access public
1570          *
1571          * @see wp_get_nav_menu_object()
1572          *
1573          * @return array Instance data.
1574          */
1575         public function value() {
1576                 if ( $this->is_previewed && $this->_previewed_blog_id === get_current_blog_id() ) {
1577                         $undefined  = new stdClass(); // Symbol.
1578                         $post_value = $this->post_value( $undefined );
1579
1580                         if ( $undefined === $post_value ) {
1581                                 $value = $this->_original_value;
1582                         } else {
1583                                 $value = $post_value;
1584                         }
1585                 } else {
1586                         $value = false;
1587
1588                         // Note that a term_id of less than one indicates a nav_menu not yet inserted.
1589                         if ( $this->term_id > 0 ) {
1590                                 $term = wp_get_nav_menu_object( $this->term_id );
1591
1592                                 if ( $term ) {
1593                                         $value = wp_array_slice_assoc( (array) $term, array_keys( $this->default ) );
1594
1595                                         $nav_menu_options  = (array) get_option( 'nav_menu_options', array() );
1596                                         $value['auto_add'] = false;
1597
1598                                         if ( isset( $nav_menu_options['auto_add'] ) && is_array( $nav_menu_options['auto_add'] ) ) {
1599                                                 $value['auto_add'] = in_array( $term->term_id, $nav_menu_options['auto_add'] );
1600                                         }
1601                                 }
1602                         }
1603
1604                         if ( ! is_array( $value ) ) {
1605                                 $value = $this->default;
1606                         }
1607                 }
1608                 return $value;
1609         }
1610
1611         /**
1612          * Handle previewing the setting.
1613          *
1614          * @since 4.3.0
1615          * @access public
1616          *
1617          * @see WP_Customize_Manager::post_value()
1618          */
1619         public function preview() {
1620                 if ( $this->is_previewed ) {
1621                         return;
1622                 }
1623
1624                 $this->is_previewed       = true;
1625                 $this->_original_value    = $this->value();
1626                 $this->_previewed_blog_id = get_current_blog_id();
1627
1628                 add_filter( 'wp_get_nav_menus', array( $this, 'filter_wp_get_nav_menus' ), 10, 2 );
1629                 add_filter( 'wp_get_nav_menu_object', array( $this, 'filter_wp_get_nav_menu_object' ), 10, 2 );
1630                 add_filter( 'default_option_nav_menu_options', array( $this, 'filter_nav_menu_options' ) );
1631                 add_filter( 'option_nav_menu_options', array( $this, 'filter_nav_menu_options' ) );
1632         }
1633
1634         /**
1635          * Filter the wp_get_nav_menus() result to ensure the inserted menu object is included, and the deleted one is removed.
1636          *
1637          * @since 4.3.0
1638          * @access public
1639          *
1640          * @see wp_get_nav_menus()
1641          *
1642          * @param array $menus An array of menu objects.
1643          * @param array $args  An array of arguments used to retrieve menu objects.
1644          * @return array
1645          */
1646         public function filter_wp_get_nav_menus( $menus, $args ) {
1647                 if ( get_current_blog_id() !== $this->_previewed_blog_id ) {
1648                         return $menus;
1649                 }
1650
1651                 $setting_value = $this->value();
1652                 $is_delete = ( false === $setting_value );
1653                 $index = -1;
1654
1655                 // Find the existing menu item's position in the list.
1656                 foreach ( $menus as $i => $menu ) {
1657                         if ( (int) $this->term_id === (int) $menu->term_id || (int) $this->previous_term_id === (int) $menu->term_id ) {
1658                                 $index = $i;
1659                                 break;
1660                         }
1661                 }
1662
1663                 if ( $is_delete ) {
1664                         // Handle deleted menu by removing it from the list.
1665                         if ( -1 !== $index ) {
1666                                 array_splice( $menus, $index, 1 );
1667                         }
1668                 } else {
1669                         // Handle menus being updated or inserted.
1670                         $menu_obj = (object) array_merge( array(
1671                                 'term_id'          => $this->term_id,
1672                                 'term_taxonomy_id' => $this->term_id,
1673                                 'slug'             => sanitize_title( $setting_value['name'] ),
1674                                 'count'            => 0,
1675                                 'term_group'       => 0,
1676                                 'taxonomy'         => self::TAXONOMY,
1677                                 'filter'           => 'raw',
1678                         ), $setting_value );
1679
1680                         array_splice( $menus, $index, ( -1 === $index ? 0 : 1 ), array( $menu_obj ) );
1681                 }
1682
1683                 // Make sure the menu objects get re-sorted after an update/insert.
1684                 if ( ! $is_delete && ! empty( $args['orderby'] ) ) {
1685                         $this->_current_menus_sort_orderby = $args['orderby'];
1686                         usort( $menus, array( $this, '_sort_menus_by_orderby' ) );
1687                 }
1688                 // @todo add support for $args['hide_empty'] === true
1689
1690                 return $menus;
1691         }
1692
1693         /**
1694          * Temporary non-closure passing of orderby value to function.
1695          *
1696          * @since 4.3.0
1697          * @access protected
1698          * @var string
1699          *
1700          * @see WP_Customize_Nav_Menu_Setting::filter_wp_get_nav_menus()
1701          * @see WP_Customize_Nav_Menu_Setting::_sort_menus_by_orderby()
1702          */
1703         protected $_current_menus_sort_orderby;
1704
1705         /**
1706          * Sort menu objects by the class-supplied orderby property.
1707          *
1708          * This is a workaround for a lack of closures.
1709          *
1710          * @since 4.3.0
1711          * @access protected
1712          * @param object $menu1
1713          * @param object $menu2
1714          * @return int
1715          *
1716          * @see WP_Customize_Nav_Menu_Setting::filter_wp_get_nav_menus()
1717          */
1718         protected function _sort_menus_by_orderby( $menu1, $menu2 ) {
1719                 $key = $this->_current_menus_sort_orderby;
1720                 return strcmp( $menu1->$key, $menu2->$key );
1721         }
1722
1723         /**
1724          * Filter the wp_get_nav_menu_object() result to supply the previewed menu object.
1725          *
1726          * Requesting a nav_menu object by anything but ID is not supported.
1727          *
1728          * @since 4.3.0
1729          * @access public
1730          *
1731          * @see wp_get_nav_menu_object()
1732          *
1733          * @param object|null $menu_obj Object returned by wp_get_nav_menu_object().
1734          * @param string      $menu_id  ID of the nav_menu term. Requests by slug or name will be ignored.
1735          * @return object|null
1736          */
1737         public function filter_wp_get_nav_menu_object( $menu_obj, $menu_id ) {
1738                 $ok = (
1739                         get_current_blog_id() === $this->_previewed_blog_id
1740                         &&
1741                         is_int( $menu_id )
1742                         &&
1743                         $menu_id === $this->term_id
1744                 );
1745                 if ( ! $ok ) {
1746                         return $menu_obj;
1747                 }
1748
1749                 $setting_value = $this->value();
1750
1751                 // Handle deleted menus.
1752                 if ( false === $setting_value ) {
1753                         return false;
1754                 }
1755
1756                 // Handle sanitization failure by preventing short-circuiting.
1757                 if ( null === $setting_value ) {
1758                         return $menu_obj;
1759                 }
1760
1761                 $menu_obj = (object) array_merge( array(
1762                                 'term_id'          => $this->term_id,
1763                                 'term_taxonomy_id' => $this->term_id,
1764                                 'slug'             => sanitize_title( $setting_value['name'] ),
1765                                 'count'            => 0,
1766                                 'term_group'       => 0,
1767                                 'taxonomy'         => self::TAXONOMY,
1768                                 'filter'           => 'raw',
1769                         ), $setting_value );
1770
1771                 return $menu_obj;
1772         }
1773
1774         /**
1775          * Filter the nav_menu_options option to include this menu's auto_add preference.
1776          *
1777          * @since 4.3.0
1778          * @access public
1779          *
1780          * @param array $nav_menu_options Nav menu options including auto_add.
1781          * @return array (Kaybe) modified nav menu options.
1782          */
1783         public function filter_nav_menu_options( $nav_menu_options ) {
1784                 if ( $this->_previewed_blog_id !== get_current_blog_id() ) {
1785                         return $nav_menu_options;
1786                 }
1787
1788                 $menu = $this->value();
1789                 $nav_menu_options = $this->filter_nav_menu_options_value(
1790                         $nav_menu_options,
1791                         $this->term_id,
1792                         false === $menu ? false : $menu['auto_add']
1793                 );
1794
1795                 return $nav_menu_options;
1796         }
1797
1798         /**
1799          * Sanitize an input.
1800          *
1801          * Note that parent::sanitize() erroneously does wp_unslash() on $value, but
1802          * we remove that in this override.
1803          *
1804          * @since 4.3.0
1805          * @access public
1806          *
1807          * @param array $value The value to sanitize.
1808          * @return array|false|null Null if an input isn't valid. False if it is marked for deletion.
1809          *                          Otherwise the sanitized value.
1810          */
1811         public function sanitize( $value ) {
1812                 // Menu is marked for deletion.
1813                 if ( false === $value ) {
1814                         return $value;
1815                 }
1816
1817                 // Invalid.
1818                 if ( ! is_array( $value ) ) {
1819                         return null;
1820                 }
1821
1822                 $default = array(
1823                         'name'        => '',
1824                         'description' => '',
1825                         'parent'      => 0,
1826                         'auto_add'    => false,
1827                 );
1828                 $value = array_merge( $default, $value );
1829                 $value = wp_array_slice_assoc( $value, array_keys( $default ) );
1830
1831                 $value['name']        = trim( esc_html( $value['name'] ) ); // This sanitization code is used in wp-admin/nav-menus.php.
1832                 $value['description'] = sanitize_text_field( $value['description'] );
1833                 $value['parent']      = max( 0, intval( $value['parent'] ) );
1834                 $value['auto_add']    = ! empty( $value['auto_add'] );
1835
1836                 if ( '' === $value['name'] ) {
1837                         $value['name'] = _x( '(unnamed)', 'Missing menu name.' );
1838                 }
1839
1840                 /** This filter is documented in wp-includes/class-wp-customize-setting.php */
1841                 return apply_filters( "customize_sanitize_{$this->id}", $value, $this );
1842         }
1843
1844         /**
1845          * Storage for data to be sent back to client in customize_save_response filter.
1846          *
1847          * @access protected
1848          * @since 4.3.0
1849          * @var array
1850          *
1851          * @see WP_Customize_Nav_Menu_Setting::amend_customize_save_response()
1852          */
1853         protected $_widget_nav_menu_updates = array();
1854
1855         /**
1856          * Create/update the nav_menu term for this setting.
1857          *
1858          * Any created menus will have their assigned term IDs exported to the client
1859          * via the customize_save_response filter. Likewise, any errors will be exported
1860          * to the client via the customize_save_response() filter.
1861          *
1862          * To delete a menu, the client can send false as the value.
1863          *
1864          * @since 4.3.0
1865          * @access protected
1866          *
1867          * @see wp_update_nav_menu_object()
1868          *
1869          * @param array|false $value {
1870          *     The value to update. Note that slug cannot be updated via wp_update_nav_menu_object().
1871          *     If false, then the menu will be deleted entirely.
1872          *
1873          *     @type string $name        The name of the menu to save.
1874          *     @type string $description The term description. Default empty string.
1875          *     @type int    $parent      The id of the parent term. Default 0.
1876          *     @type bool   $auto_add    Whether pages will auto_add to this menu. Default false.
1877          * }
1878          * @return null|void
1879          */
1880         protected function update( $value ) {
1881                 if ( $this->is_updated ) {
1882                         return;
1883                 }
1884
1885                 $this->is_updated = true;
1886                 $is_placeholder   = ( $this->term_id < 0 );
1887                 $is_delete        = ( false === $value );
1888
1889                 add_filter( 'customize_save_response', array( $this, 'amend_customize_save_response' ) );
1890
1891                 $auto_add = null;
1892                 if ( $is_delete ) {
1893                         // If the current setting term is a placeholder, a delete request is a no-op.
1894                         if ( $is_placeholder ) {
1895                                 $this->update_status = 'deleted';
1896                         } else {
1897                                 $r = wp_delete_nav_menu( $this->term_id );
1898
1899                                 if ( is_wp_error( $r ) ) {
1900                                         $this->update_status = 'error';
1901                                         $this->update_error  = $r;
1902                                 } else {
1903                                         $this->update_status = 'deleted';
1904                                         $auto_add = false;
1905                                 }
1906                         }
1907                 } else {
1908                         // Insert or update menu.
1909                         $menu_data = wp_array_slice_assoc( $value, array( 'description', 'parent' ) );
1910                         $menu_data['menu-name'] = $value['name'];
1911
1912                         $menu_id = $is_placeholder ? 0 : $this->term_id;
1913                         $r = wp_update_nav_menu_object( $menu_id, $menu_data );
1914                         $original_name = $menu_data['menu-name'];
1915                         $name_conflict_suffix = 1;
1916                         while ( is_wp_error( $r ) && 'menu_exists' === $r->get_error_code() ) {
1917                                 $name_conflict_suffix += 1;
1918                                 /* translators: 1: original menu name, 2: duplicate count */
1919                                 $menu_data['menu-name'] = sprintf( __( '%1$s (%2$d)' ), $original_name, $name_conflict_suffix );
1920                                 $r = wp_update_nav_menu_object( $menu_id, $menu_data );
1921                         }
1922
1923                         if ( is_wp_error( $r ) ) {
1924                                 $this->update_status = 'error';
1925                                 $this->update_error  = $r;
1926                         } else {
1927                                 if ( $is_placeholder ) {
1928                                         $this->previous_term_id = $this->term_id;
1929                                         $this->term_id          = $r;
1930                                         $this->update_status    = 'inserted';
1931                                 } else {
1932                                         $this->update_status = 'updated';
1933                                 }
1934
1935                                 $auto_add = $value['auto_add'];
1936                         }
1937                 }
1938
1939                 if ( null !== $auto_add ) {
1940                         $nav_menu_options = $this->filter_nav_menu_options_value(
1941                                 (array) get_option( 'nav_menu_options', array() ),
1942                                 $this->term_id,
1943                                 $auto_add
1944                         );
1945                         update_option( 'nav_menu_options', $nav_menu_options );
1946                 }
1947
1948                 if ( 'inserted' === $this->update_status ) {
1949                         // Make sure that new menus assigned to nav menu locations use their new IDs.
1950                         foreach ( $this->manager->settings() as $setting ) {
1951                                 if ( ! preg_match( '/^nav_menu_locations\[/', $setting->id ) ) {
1952                                         continue;
1953                                 }
1954
1955                                 $post_value = $setting->post_value( null );
1956                                 if ( ! is_null( $post_value ) && $this->previous_term_id === intval( $post_value ) ) {
1957                                         $this->manager->set_post_value( $setting->id, $this->term_id );
1958                                         $setting->save();
1959                                 }
1960                         }
1961
1962                         // Make sure that any nav_menu widgets referencing the placeholder nav menu get updated and sent back to client.
1963                         foreach ( array_keys( $this->manager->unsanitized_post_values() ) as $setting_id ) {
1964                                 $nav_menu_widget_setting = $this->manager->get_setting( $setting_id );
1965                                 if ( ! $nav_menu_widget_setting || ! preg_match( '/^widget_nav_menu\[/', $nav_menu_widget_setting->id ) ) {
1966                                         continue;
1967                                 }
1968
1969                                 $widget_instance = $nav_menu_widget_setting->post_value(); // Note that this calls WP_Customize_Widgets::sanitize_widget_instance().
1970                                 if ( empty( $widget_instance['nav_menu'] ) || intval( $widget_instance['nav_menu'] ) !== $this->previous_term_id ) {
1971                                         continue;
1972                                 }
1973
1974                                 $widget_instance['nav_menu'] = $this->term_id;
1975                                 $updated_widget_instance = $this->manager->widgets->sanitize_widget_js_instance( $widget_instance );
1976                                 $this->manager->set_post_value( $nav_menu_widget_setting->id, $updated_widget_instance );
1977                                 $nav_menu_widget_setting->save();
1978
1979                                 $this->_widget_nav_menu_updates[ $nav_menu_widget_setting->id ] = $updated_widget_instance;
1980                         }
1981                 }
1982         }
1983
1984         /**
1985          * Updates a nav_menu_options array.
1986          *
1987          * @since 4.3.0
1988          * @access protected
1989          *
1990          * @see WP_Customize_Nav_Menu_Setting::filter_nav_menu_options()
1991          * @see WP_Customize_Nav_Menu_Setting::update()
1992          *
1993          * @param array $nav_menu_options Array as returned by get_option( 'nav_menu_options' ).
1994          * @param int   $menu_id          The term ID for the given menu.
1995          * @param bool  $auto_add         Whether to auto-add or not.
1996          * @return array (Maybe) modified nav_menu_otions array.
1997          */
1998         protected function filter_nav_menu_options_value( $nav_menu_options, $menu_id, $auto_add ) {
1999                 $nav_menu_options = (array) $nav_menu_options;
2000                 if ( ! isset( $nav_menu_options['auto_add'] ) ) {
2001                         $nav_menu_options['auto_add'] = array();
2002                 }
2003
2004                 $i = array_search( $menu_id, $nav_menu_options['auto_add'] );
2005                 if ( $auto_add && false === $i ) {
2006                         array_push( $nav_menu_options['auto_add'], $this->term_id );
2007                 } elseif ( ! $auto_add && false !== $i ) {
2008                         array_splice( $nav_menu_options['auto_add'], $i, 1 );
2009                 }
2010
2011                 return $nav_menu_options;
2012         }
2013
2014         /**
2015          * Export data for the JS client.
2016          *
2017          * @since 4.3.0
2018          * @access public
2019          *
2020          * @see WP_Customize_Nav_Menu_Setting::update()
2021          *
2022          * @param array $data Additional information passed back to the 'saved' event on `wp.customize`.
2023          * @return array Export data.
2024          */
2025         public function amend_customize_save_response( $data ) {
2026                 if ( ! isset( $data['nav_menu_updates'] ) ) {
2027                         $data['nav_menu_updates'] = array();
2028                 }
2029                 if ( ! isset( $data['widget_nav_menu_updates'] ) ) {
2030                         $data['widget_nav_menu_updates'] = array();
2031                 }
2032
2033                 $data['nav_menu_updates'][] = array(
2034                         'term_id'          => $this->term_id,
2035                         'previous_term_id' => $this->previous_term_id,
2036                         'error'            => $this->update_error ? $this->update_error->get_error_code() : null,
2037                         'status'           => $this->update_status,
2038                         'saved_value'      => 'deleted' === $this->update_status ? null : $this->value(),
2039                 );
2040
2041                 $data['widget_nav_menu_updates'] = array_merge(
2042                         $data['widget_nav_menu_updates'],
2043                         $this->_widget_nav_menu_updates
2044                 );
2045                 $this->_widget_nav_menu_updates = array();
2046
2047                 return $data;
2048         }
2049 }