class-wp-customize-nav-menu-item-setting.php 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898
  1. <?php
  2. /**
  3. * Customize API: WP_Customize_Nav_Menu_Item_Setting class
  4. *
  5. * @package WordPress
  6. * @subpackage Customize
  7. * @since 4.4.0
  8. */
  9. /**
  10. * Customize Setting to represent a nav_menu.
  11. *
  12. * Subclass of WP_Customize_Setting to represent a nav_menu taxonomy term, and
  13. * the IDs for the nav_menu_items associated with the nav menu.
  14. *
  15. * @since 4.3.0
  16. *
  17. * @see WP_Customize_Setting
  18. */
  19. class WP_Customize_Nav_Menu_Item_Setting extends WP_Customize_Setting {
  20. const ID_PATTERN = '/^nav_menu_item\[(?P<id>-?\d+)\]$/';
  21. const POST_TYPE = 'nav_menu_item';
  22. const TYPE = 'nav_menu_item';
  23. /**
  24. * Setting type.
  25. *
  26. * @since 4.3.0
  27. * @var string
  28. */
  29. public $type = self::TYPE;
  30. /**
  31. * Default setting value.
  32. *
  33. * @since 4.3.0
  34. * @var array
  35. *
  36. * @see wp_setup_nav_menu_item()
  37. */
  38. public $default = array(
  39. // The $menu_item_data for wp_update_nav_menu_item().
  40. 'object_id' => 0,
  41. 'object' => '', // Taxonomy name.
  42. 'menu_item_parent' => 0, // A.K.A. menu-item-parent-id; note that post_parent is different, and not included.
  43. 'position' => 0, // A.K.A. menu_order.
  44. 'type' => 'custom', // Note that type_label is not included here.
  45. 'title' => '',
  46. 'url' => '',
  47. 'target' => '',
  48. 'attr_title' => '',
  49. 'description' => '',
  50. 'classes' => '',
  51. 'xfn' => '',
  52. 'status' => 'publish',
  53. 'original_title' => '',
  54. 'nav_menu_term_id' => 0, // This will be supplied as the $menu_id arg for wp_update_nav_menu_item().
  55. '_invalid' => false,
  56. );
  57. /**
  58. * Default transport.
  59. *
  60. * @since 4.3.0
  61. * @since 4.5.0 Default changed to 'refresh'
  62. * @var string
  63. */
  64. public $transport = 'refresh';
  65. /**
  66. * The post ID represented by this setting instance. This is the db_id.
  67. *
  68. * A negative value represents a placeholder ID for a new menu not yet saved.
  69. *
  70. * @since 4.3.0
  71. * @var int
  72. */
  73. public $post_id;
  74. /**
  75. * Storage of pre-setup menu item to prevent wasted calls to wp_setup_nav_menu_item().
  76. *
  77. * @since 4.3.0
  78. * @var array|null
  79. */
  80. protected $value;
  81. /**
  82. * Previous (placeholder) post ID used before creating a new menu item.
  83. *
  84. * This value will be exported to JS via the customize_save_response filter
  85. * so that JavaScript can update the settings to refer to the newly-assigned
  86. * post ID. This value is always negative to indicate it does not refer to
  87. * a real post.
  88. *
  89. * @since 4.3.0
  90. * @var int
  91. *
  92. * @see WP_Customize_Nav_Menu_Item_Setting::update()
  93. * @see WP_Customize_Nav_Menu_Item_Setting::amend_customize_save_response()
  94. */
  95. public $previous_post_id;
  96. /**
  97. * When previewing or updating a menu item, this stores the previous nav_menu_term_id
  98. * which ensures that we can apply the proper filters.
  99. *
  100. * @since 4.3.0
  101. * @var int
  102. */
  103. public $original_nav_menu_term_id;
  104. /**
  105. * Whether or not update() was called.
  106. *
  107. * @since 4.3.0
  108. * @var bool
  109. */
  110. protected $is_updated = false;
  111. /**
  112. * Status for calling the update method, used in customize_save_response filter.
  113. *
  114. * See {@see 'customize_save_response'}.
  115. *
  116. * When status is inserted, the placeholder post ID is stored in $previous_post_id.
  117. * When status is error, the error is stored in $update_error.
  118. *
  119. * @since 4.3.0
  120. * @var string updated|inserted|deleted|error
  121. *
  122. * @see WP_Customize_Nav_Menu_Item_Setting::update()
  123. * @see WP_Customize_Nav_Menu_Item_Setting::amend_customize_save_response()
  124. */
  125. public $update_status;
  126. /**
  127. * Any error object returned by wp_update_nav_menu_item() when setting is updated.
  128. *
  129. * @since 4.3.0
  130. * @var WP_Error
  131. *
  132. * @see WP_Customize_Nav_Menu_Item_Setting::update()
  133. * @see WP_Customize_Nav_Menu_Item_Setting::amend_customize_save_response()
  134. */
  135. public $update_error;
  136. /**
  137. * Constructor.
  138. *
  139. * Any supplied $args override class property defaults.
  140. *
  141. * @since 4.3.0
  142. *
  143. * @param WP_Customize_Manager $manager Bootstrap Customizer instance.
  144. * @param string $id An specific ID of the setting. Can be a
  145. * theme mod or option name.
  146. * @param array $args Optional. Setting arguments.
  147. *
  148. * @throws Exception If $id is not valid for this setting type.
  149. */
  150. public function __construct( WP_Customize_Manager $manager, $id, array $args = array() ) {
  151. if ( empty( $manager->nav_menus ) ) {
  152. throw new Exception( 'Expected WP_Customize_Manager::$nav_menus to be set.' );
  153. }
  154. if ( ! preg_match( self::ID_PATTERN, $id, $matches ) ) {
  155. throw new Exception( "Illegal widget setting ID: $id" );
  156. }
  157. $this->post_id = intval( $matches['id'] );
  158. add_action( 'wp_update_nav_menu_item', array( $this, 'flush_cached_value' ), 10, 2 );
  159. parent::__construct( $manager, $id, $args );
  160. // Ensure that an initially-supplied value is valid.
  161. if ( isset( $this->value ) ) {
  162. $this->populate_value();
  163. foreach ( array_diff( array_keys( $this->default ), array_keys( $this->value ) ) as $missing ) {
  164. throw new Exception( "Supplied nav_menu_item value missing property: $missing" );
  165. }
  166. }
  167. }
  168. /**
  169. * Clear the cached value when this nav menu item is updated.
  170. *
  171. * @since 4.3.0
  172. *
  173. * @param int $menu_id The term ID for the menu.
  174. * @param int $menu_item_id The post ID for the menu item.
  175. */
  176. public function flush_cached_value( $menu_id, $menu_item_id ) {
  177. unset( $menu_id );
  178. if ( $menu_item_id === $this->post_id ) {
  179. $this->value = null;
  180. }
  181. }
  182. /**
  183. * Get the instance data for a given nav_menu_item setting.
  184. *
  185. * @since 4.3.0
  186. *
  187. * @see wp_setup_nav_menu_item()
  188. *
  189. * @return array|false Instance data array, or false if the item is marked for deletion.
  190. */
  191. public function value() {
  192. if ( $this->is_previewed && $this->_previewed_blog_id === get_current_blog_id() ) {
  193. $undefined = new stdClass(); // Symbol.
  194. $post_value = $this->post_value( $undefined );
  195. if ( $undefined === $post_value ) {
  196. $value = $this->_original_value;
  197. } else {
  198. $value = $post_value;
  199. }
  200. if ( ! empty( $value ) && empty( $value['original_title'] ) ) {
  201. $value['original_title'] = $this->get_original_title( (object) $value );
  202. }
  203. } elseif ( isset( $this->value ) ) {
  204. $value = $this->value;
  205. } else {
  206. $value = false;
  207. // Note that a ID of less than one indicates a nav_menu not yet inserted.
  208. if ( $this->post_id > 0 ) {
  209. $post = get_post( $this->post_id );
  210. if ( $post && self::POST_TYPE === $post->post_type ) {
  211. $is_title_empty = empty( $post->post_title );
  212. $value = (array) wp_setup_nav_menu_item( $post );
  213. if ( $is_title_empty ) {
  214. $value['title'] = '';
  215. }
  216. }
  217. }
  218. if ( ! is_array( $value ) ) {
  219. $value = $this->default;
  220. }
  221. // Cache the value for future calls to avoid having to re-call wp_setup_nav_menu_item().
  222. $this->value = $value;
  223. $this->populate_value();
  224. $value = $this->value;
  225. }
  226. if ( ! empty( $value ) && empty( $value['type_label'] ) ) {
  227. $value['type_label'] = $this->get_type_label( (object) $value );
  228. }
  229. return $value;
  230. }
  231. /**
  232. * Get original title.
  233. *
  234. * @since 4.7.0
  235. *
  236. * @param object $item Nav menu item.
  237. * @return string The original title.
  238. */
  239. protected function get_original_title( $item ) {
  240. $original_title = '';
  241. if ( 'post_type' === $item->type && ! empty( $item->object_id ) ) {
  242. $original_object = get_post( $item->object_id );
  243. if ( $original_object ) {
  244. /** This filter is documented in wp-includes/post-template.php */
  245. $original_title = apply_filters( 'the_title', $original_object->post_title, $original_object->ID );
  246. if ( '' === $original_title ) {
  247. /* translators: %d: ID of a post. */
  248. $original_title = sprintf( __( '#%d (no title)' ), $original_object->ID );
  249. }
  250. }
  251. } elseif ( 'taxonomy' === $item->type && ! empty( $item->object_id ) ) {
  252. $original_term_title = get_term_field( 'name', $item->object_id, $item->object, 'raw' );
  253. if ( ! is_wp_error( $original_term_title ) ) {
  254. $original_title = $original_term_title;
  255. }
  256. } elseif ( 'post_type_archive' === $item->type ) {
  257. $original_object = get_post_type_object( $item->object );
  258. if ( $original_object ) {
  259. $original_title = $original_object->labels->archives;
  260. }
  261. }
  262. $original_title = html_entity_decode( $original_title, ENT_QUOTES, get_bloginfo( 'charset' ) );
  263. return $original_title;
  264. }
  265. /**
  266. * Get type label.
  267. *
  268. * @since 4.7.0
  269. *
  270. * @param object $item Nav menu item.
  271. * @returns string The type label.
  272. */
  273. protected function get_type_label( $item ) {
  274. if ( 'post_type' === $item->type ) {
  275. $object = get_post_type_object( $item->object );
  276. if ( $object ) {
  277. $type_label = $object->labels->singular_name;
  278. } else {
  279. $type_label = $item->object;
  280. }
  281. } elseif ( 'taxonomy' === $item->type ) {
  282. $object = get_taxonomy( $item->object );
  283. if ( $object ) {
  284. $type_label = $object->labels->singular_name;
  285. } else {
  286. $type_label = $item->object;
  287. }
  288. } elseif ( 'post_type_archive' === $item->type ) {
  289. $type_label = __( 'Post Type Archive' );
  290. } else {
  291. $type_label = __( 'Custom Link' );
  292. }
  293. return $type_label;
  294. }
  295. /**
  296. * Ensure that the value is fully populated with the necessary properties.
  297. *
  298. * Translates some properties added by wp_setup_nav_menu_item() and removes others.
  299. *
  300. * @since 4.3.0
  301. *
  302. * @see WP_Customize_Nav_Menu_Item_Setting::value()
  303. */
  304. protected function populate_value() {
  305. if ( ! is_array( $this->value ) ) {
  306. return;
  307. }
  308. if ( isset( $this->value['menu_order'] ) ) {
  309. $this->value['position'] = $this->value['menu_order'];
  310. unset( $this->value['menu_order'] );
  311. }
  312. if ( isset( $this->value['post_status'] ) ) {
  313. $this->value['status'] = $this->value['post_status'];
  314. unset( $this->value['post_status'] );
  315. }
  316. if ( ! isset( $this->value['original_title'] ) ) {
  317. $this->value['original_title'] = $this->get_original_title( (object) $this->value );
  318. }
  319. if ( ! isset( $this->value['nav_menu_term_id'] ) && $this->post_id > 0 ) {
  320. $menus = wp_get_post_terms(
  321. $this->post_id,
  322. WP_Customize_Nav_Menu_Setting::TAXONOMY,
  323. array(
  324. 'fields' => 'ids',
  325. )
  326. );
  327. if ( ! empty( $menus ) ) {
  328. $this->value['nav_menu_term_id'] = array_shift( $menus );
  329. } else {
  330. $this->value['nav_menu_term_id'] = 0;
  331. }
  332. }
  333. foreach ( array( 'object_id', 'menu_item_parent', 'nav_menu_term_id' ) as $key ) {
  334. if ( ! is_int( $this->value[ $key ] ) ) {
  335. $this->value[ $key ] = intval( $this->value[ $key ] );
  336. }
  337. }
  338. foreach ( array( 'classes', 'xfn' ) as $key ) {
  339. if ( is_array( $this->value[ $key ] ) ) {
  340. $this->value[ $key ] = implode( ' ', $this->value[ $key ] );
  341. }
  342. }
  343. if ( ! isset( $this->value['title'] ) ) {
  344. $this->value['title'] = '';
  345. }
  346. if ( ! isset( $this->value['_invalid'] ) ) {
  347. $this->value['_invalid'] = false;
  348. $is_known_invalid = (
  349. ( ( 'post_type' === $this->value['type'] || 'post_type_archive' === $this->value['type'] ) && ! post_type_exists( $this->value['object'] ) )
  350. ||
  351. ( 'taxonomy' === $this->value['type'] && ! taxonomy_exists( $this->value['object'] ) )
  352. );
  353. if ( $is_known_invalid ) {
  354. $this->value['_invalid'] = true;
  355. }
  356. }
  357. // Remove remaining properties available on a setup nav_menu_item post object which aren't relevant to the setting value.
  358. $irrelevant_properties = array(
  359. 'ID',
  360. 'comment_count',
  361. 'comment_status',
  362. 'db_id',
  363. 'filter',
  364. 'guid',
  365. 'ping_status',
  366. 'pinged',
  367. 'post_author',
  368. 'post_content',
  369. 'post_content_filtered',
  370. 'post_date',
  371. 'post_date_gmt',
  372. 'post_excerpt',
  373. 'post_mime_type',
  374. 'post_modified',
  375. 'post_modified_gmt',
  376. 'post_name',
  377. 'post_parent',
  378. 'post_password',
  379. 'post_title',
  380. 'post_type',
  381. 'to_ping',
  382. );
  383. foreach ( $irrelevant_properties as $property ) {
  384. unset( $this->value[ $property ] );
  385. }
  386. }
  387. /**
  388. * Handle previewing the setting.
  389. *
  390. * @since 4.3.0
  391. * @since 4.4.0 Added boolean return value.
  392. *
  393. * @see WP_Customize_Manager::post_value()
  394. *
  395. * @return bool False if method short-circuited due to no-op.
  396. */
  397. public function preview() {
  398. if ( $this->is_previewed ) {
  399. return false;
  400. }
  401. $undefined = new stdClass();
  402. $is_placeholder = ( $this->post_id < 0 );
  403. $is_dirty = ( $undefined !== $this->post_value( $undefined ) );
  404. if ( ! $is_placeholder && ! $is_dirty ) {
  405. return false;
  406. }
  407. $this->is_previewed = true;
  408. $this->_original_value = $this->value();
  409. $this->original_nav_menu_term_id = $this->_original_value['nav_menu_term_id'];
  410. $this->_previewed_blog_id = get_current_blog_id();
  411. add_filter( 'wp_get_nav_menu_items', array( $this, 'filter_wp_get_nav_menu_items' ), 10, 3 );
  412. $sort_callback = array( __CLASS__, 'sort_wp_get_nav_menu_items' );
  413. if ( ! has_filter( 'wp_get_nav_menu_items', $sort_callback ) ) {
  414. add_filter( 'wp_get_nav_menu_items', array( __CLASS__, 'sort_wp_get_nav_menu_items' ), 1000, 3 );
  415. }
  416. // @todo Add get_post_metadata filters for plugins to add their data.
  417. return true;
  418. }
  419. /**
  420. * Filters the wp_get_nav_menu_items() result to supply the previewed menu items.
  421. *
  422. * @since 4.3.0
  423. *
  424. * @see wp_get_nav_menu_items()
  425. *
  426. * @param WP_Post[] $items An array of menu item post objects.
  427. * @param WP_Term $menu The menu object.
  428. * @param array $args An array of arguments used to retrieve menu item objects.
  429. * @return WP_Post[] Array of menu item objects.
  430. */
  431. public function filter_wp_get_nav_menu_items( $items, $menu, $args ) {
  432. $this_item = $this->value();
  433. $current_nav_menu_term_id = null;
  434. if ( isset( $this_item['nav_menu_term_id'] ) ) {
  435. $current_nav_menu_term_id = $this_item['nav_menu_term_id'];
  436. unset( $this_item['nav_menu_term_id'] );
  437. }
  438. $should_filter = (
  439. $menu->term_id === $this->original_nav_menu_term_id
  440. ||
  441. $menu->term_id === $current_nav_menu_term_id
  442. );
  443. if ( ! $should_filter ) {
  444. return $items;
  445. }
  446. // Handle deleted menu item, or menu item moved to another menu.
  447. $should_remove = (
  448. false === $this_item
  449. ||
  450. ( isset( $this_item['_invalid'] ) && true === $this_item['_invalid'] )
  451. ||
  452. (
  453. $this->original_nav_menu_term_id === $menu->term_id
  454. &&
  455. $current_nav_menu_term_id !== $this->original_nav_menu_term_id
  456. )
  457. );
  458. if ( $should_remove ) {
  459. $filtered_items = array();
  460. foreach ( $items as $item ) {
  461. if ( $item->db_id !== $this->post_id ) {
  462. $filtered_items[] = $item;
  463. }
  464. }
  465. return $filtered_items;
  466. }
  467. $mutated = false;
  468. $should_update = (
  469. is_array( $this_item )
  470. &&
  471. $current_nav_menu_term_id === $menu->term_id
  472. );
  473. if ( $should_update ) {
  474. foreach ( $items as $item ) {
  475. if ( $item->db_id === $this->post_id ) {
  476. foreach ( get_object_vars( $this->value_as_wp_post_nav_menu_item() ) as $key => $value ) {
  477. $item->$key = $value;
  478. }
  479. $mutated = true;
  480. }
  481. }
  482. // Not found so we have to append it..
  483. if ( ! $mutated ) {
  484. $items[] = $this->value_as_wp_post_nav_menu_item();
  485. }
  486. }
  487. return $items;
  488. }
  489. /**
  490. * Re-apply the tail logic also applied on $items by wp_get_nav_menu_items().
  491. *
  492. * @since 4.3.0
  493. *
  494. * @see wp_get_nav_menu_items()
  495. *
  496. * @param WP_Post[] $items An array of menu item post objects.
  497. * @param WP_Term $menu The menu object.
  498. * @param array $args An array of arguments used to retrieve menu item objects.
  499. * @return WP_Post[] Array of menu item objects.
  500. */
  501. public static function sort_wp_get_nav_menu_items( $items, $menu, $args ) {
  502. // @todo We should probably re-apply some constraints imposed by $args.
  503. unset( $args['include'] );
  504. // Remove invalid items only in front end.
  505. if ( ! is_admin() ) {
  506. $items = array_filter( $items, '_is_valid_nav_menu_item' );
  507. }
  508. if ( ARRAY_A === $args['output'] ) {
  509. $items = wp_list_sort(
  510. $items,
  511. array(
  512. $args['output_key'] => 'ASC',
  513. )
  514. );
  515. $i = 1;
  516. foreach ( $items as $k => $item ) {
  517. $items[ $k ]->{$args['output_key']} = $i++;
  518. }
  519. }
  520. return $items;
  521. }
  522. /**
  523. * Get the value emulated into a WP_Post and set up as a nav_menu_item.
  524. *
  525. * @since 4.3.0
  526. *
  527. * @return WP_Post With wp_setup_nav_menu_item() applied.
  528. */
  529. public function value_as_wp_post_nav_menu_item() {
  530. $item = (object) $this->value();
  531. unset( $item->nav_menu_term_id );
  532. $item->post_status = $item->status;
  533. unset( $item->status );
  534. $item->post_type = 'nav_menu_item';
  535. $item->menu_order = $item->position;
  536. unset( $item->position );
  537. if ( empty( $item->original_title ) ) {
  538. $item->original_title = $this->get_original_title( $item );
  539. }
  540. if ( empty( $item->title ) && ! empty( $item->original_title ) ) {
  541. $item->title = $item->original_title;
  542. }
  543. if ( $item->title ) {
  544. $item->post_title = $item->title;
  545. }
  546. $item->ID = $this->post_id;
  547. $item->db_id = $this->post_id;
  548. $post = new WP_Post( (object) $item );
  549. if ( empty( $post->post_author ) ) {
  550. $post->post_author = get_current_user_id();
  551. }
  552. if ( ! isset( $post->type_label ) ) {
  553. $post->type_label = $this->get_type_label( $post );
  554. }
  555. // Ensure nav menu item URL is set according to linked object.
  556. if ( 'post_type' === $post->type && ! empty( $post->object_id ) ) {
  557. $post->url = get_permalink( $post->object_id );
  558. } elseif ( 'taxonomy' === $post->type && ! empty( $post->object ) && ! empty( $post->object_id ) ) {
  559. $post->url = get_term_link( (int) $post->object_id, $post->object );
  560. } elseif ( 'post_type_archive' === $post->type && ! empty( $post->object ) ) {
  561. $post->url = get_post_type_archive_link( $post->object );
  562. }
  563. if ( is_wp_error( $post->url ) ) {
  564. $post->url = '';
  565. }
  566. /** This filter is documented in wp-includes/nav-menu.php */
  567. $post->attr_title = apply_filters( 'nav_menu_attr_title', $post->attr_title );
  568. /** This filter is documented in wp-includes/nav-menu.php */
  569. $post->description = apply_filters( 'nav_menu_description', wp_trim_words( $post->description, 200 ) );
  570. /** This filter is documented in wp-includes/nav-menu.php */
  571. $post = apply_filters( 'wp_setup_nav_menu_item', $post );
  572. return $post;
  573. }
  574. /**
  575. * Sanitize an input.
  576. *
  577. * Note that parent::sanitize() erroneously does wp_unslash() on $value, but
  578. * we remove that in this override.
  579. *
  580. * @since 4.3.0
  581. *
  582. * @param array $menu_item_value The value to sanitize.
  583. * @return array|false|null|WP_Error Null or WP_Error if an input isn't valid. False if it is marked for deletion.
  584. * Otherwise the sanitized value.
  585. */
  586. public function sanitize( $menu_item_value ) {
  587. // Menu is marked for deletion.
  588. if ( false === $menu_item_value ) {
  589. return $menu_item_value;
  590. }
  591. // Invalid.
  592. if ( ! is_array( $menu_item_value ) ) {
  593. return null;
  594. }
  595. $default = array(
  596. 'object_id' => 0,
  597. 'object' => '',
  598. 'menu_item_parent' => 0,
  599. 'position' => 0,
  600. 'type' => 'custom',
  601. 'title' => '',
  602. 'url' => '',
  603. 'target' => '',
  604. 'attr_title' => '',
  605. 'description' => '',
  606. 'classes' => '',
  607. 'xfn' => '',
  608. 'status' => 'publish',
  609. 'original_title' => '',
  610. 'nav_menu_term_id' => 0,
  611. '_invalid' => false,
  612. );
  613. $menu_item_value = array_merge( $default, $menu_item_value );
  614. $menu_item_value = wp_array_slice_assoc( $menu_item_value, array_keys( $default ) );
  615. $menu_item_value['position'] = intval( $menu_item_value['position'] );
  616. foreach ( array( 'object_id', 'menu_item_parent', 'nav_menu_term_id' ) as $key ) {
  617. // Note we need to allow negative-integer IDs for previewed objects not inserted yet.
  618. $menu_item_value[ $key ] = intval( $menu_item_value[ $key ] );
  619. }
  620. foreach ( array( 'type', 'object', 'target' ) as $key ) {
  621. $menu_item_value[ $key ] = sanitize_key( $menu_item_value[ $key ] );
  622. }
  623. foreach ( array( 'xfn', 'classes' ) as $key ) {
  624. $value = $menu_item_value[ $key ];
  625. if ( ! is_array( $value ) ) {
  626. $value = explode( ' ', $value );
  627. }
  628. $menu_item_value[ $key ] = implode( ' ', array_map( 'sanitize_html_class', $value ) );
  629. }
  630. $menu_item_value['original_title'] = sanitize_text_field( $menu_item_value['original_title'] );
  631. // Apply the same filters as when calling wp_insert_post().
  632. /** This filter is documented in wp-includes/post.php */
  633. $menu_item_value['title'] = wp_unslash( apply_filters( 'title_save_pre', wp_slash( $menu_item_value['title'] ) ) );
  634. /** This filter is documented in wp-includes/post.php */
  635. $menu_item_value['attr_title'] = wp_unslash( apply_filters( 'excerpt_save_pre', wp_slash( $menu_item_value['attr_title'] ) ) );
  636. /** This filter is documented in wp-includes/post.php */
  637. $menu_item_value['description'] = wp_unslash( apply_filters( 'content_save_pre', wp_slash( $menu_item_value['description'] ) ) );
  638. if ( '' !== $menu_item_value['url'] ) {
  639. $menu_item_value['url'] = esc_url_raw( $menu_item_value['url'] );
  640. if ( '' === $menu_item_value['url'] ) {
  641. return new WP_Error( 'invalid_url', __( 'Invalid URL.' ) ); // Fail sanitization if URL is invalid.
  642. }
  643. }
  644. if ( 'publish' !== $menu_item_value['status'] ) {
  645. $menu_item_value['status'] = 'draft';
  646. }
  647. $menu_item_value['_invalid'] = (bool) $menu_item_value['_invalid'];
  648. /** This filter is documented in wp-includes/class-wp-customize-setting.php */
  649. return apply_filters( "customize_sanitize_{$this->id}", $menu_item_value, $this );
  650. }
  651. /**
  652. * Creates/updates the nav_menu_item post for this setting.
  653. *
  654. * Any created menu items will have their assigned post IDs exported to the client
  655. * via the {@see 'customize_save_response'} filter. Likewise, any errors will be
  656. * exported to the client via the customize_save_response() filter.
  657. *
  658. * To delete a menu, the client can send false as the value.
  659. *
  660. * @since 4.3.0
  661. *
  662. * @see wp_update_nav_menu_item()
  663. *
  664. * @param array|false $value The menu item array to update. If false, then the menu item will be deleted
  665. * entirely. See WP_Customize_Nav_Menu_Item_Setting::$default for what the value
  666. * should consist of.
  667. * @return null|void
  668. */
  669. protected function update( $value ) {
  670. if ( $this->is_updated ) {
  671. return;
  672. }
  673. $this->is_updated = true;
  674. $is_placeholder = ( $this->post_id < 0 );
  675. $is_delete = ( false === $value );
  676. // Update the cached value.
  677. $this->value = $value;
  678. add_filter( 'customize_save_response', array( $this, 'amend_customize_save_response' ) );
  679. if ( $is_delete ) {
  680. // If the current setting post is a placeholder, a delete request is a no-op.
  681. if ( $is_placeholder ) {
  682. $this->update_status = 'deleted';
  683. } else {
  684. $r = wp_delete_post( $this->post_id, true );
  685. if ( false === $r ) {
  686. $this->update_error = new WP_Error( 'delete_failure' );
  687. $this->update_status = 'error';
  688. } else {
  689. $this->update_status = 'deleted';
  690. }
  691. // @todo send back the IDs for all associated nav menu items deleted, so these settings (and controls) can be removed from Customizer?
  692. }
  693. } else {
  694. // Handle saving menu items for menus that are being newly-created.
  695. if ( $value['nav_menu_term_id'] < 0 ) {
  696. $nav_menu_setting_id = sprintf( 'nav_menu[%s]', $value['nav_menu_term_id'] );
  697. $nav_menu_setting = $this->manager->get_setting( $nav_menu_setting_id );
  698. if ( ! $nav_menu_setting || ! ( $nav_menu_setting instanceof WP_Customize_Nav_Menu_Setting ) ) {
  699. $this->update_status = 'error';
  700. $this->update_error = new WP_Error( 'unexpected_nav_menu_setting' );
  701. return;
  702. }
  703. if ( false === $nav_menu_setting->save() ) {
  704. $this->update_status = 'error';
  705. $this->update_error = new WP_Error( 'nav_menu_setting_failure' );
  706. return;
  707. }
  708. if ( $nav_menu_setting->previous_term_id !== intval( $value['nav_menu_term_id'] ) ) {
  709. $this->update_status = 'error';
  710. $this->update_error = new WP_Error( 'unexpected_previous_term_id' );
  711. return;
  712. }
  713. $value['nav_menu_term_id'] = $nav_menu_setting->term_id;
  714. }
  715. // Handle saving a nav menu item that is a child of a nav menu item being newly-created.
  716. if ( $value['menu_item_parent'] < 0 ) {
  717. $parent_nav_menu_item_setting_id = sprintf( 'nav_menu_item[%s]', $value['menu_item_parent'] );
  718. $parent_nav_menu_item_setting = $this->manager->get_setting( $parent_nav_menu_item_setting_id );
  719. if ( ! $parent_nav_menu_item_setting || ! ( $parent_nav_menu_item_setting instanceof WP_Customize_Nav_Menu_Item_Setting ) ) {
  720. $this->update_status = 'error';
  721. $this->update_error = new WP_Error( 'unexpected_nav_menu_item_setting' );
  722. return;
  723. }
  724. if ( false === $parent_nav_menu_item_setting->save() ) {
  725. $this->update_status = 'error';
  726. $this->update_error = new WP_Error( 'nav_menu_item_setting_failure' );
  727. return;
  728. }
  729. if ( $parent_nav_menu_item_setting->previous_post_id !== intval( $value['menu_item_parent'] ) ) {
  730. $this->update_status = 'error';
  731. $this->update_error = new WP_Error( 'unexpected_previous_post_id' );
  732. return;
  733. }
  734. $value['menu_item_parent'] = $parent_nav_menu_item_setting->post_id;
  735. }
  736. // Insert or update menu.
  737. $menu_item_data = array(
  738. 'menu-item-object-id' => $value['object_id'],
  739. 'menu-item-object' => $value['object'],
  740. 'menu-item-parent-id' => $value['menu_item_parent'],
  741. 'menu-item-position' => $value['position'],
  742. 'menu-item-type' => $value['type'],
  743. 'menu-item-title' => $value['title'],
  744. 'menu-item-url' => $value['url'],
  745. 'menu-item-description' => $value['description'],
  746. 'menu-item-attr-title' => $value['attr_title'],
  747. 'menu-item-target' => $value['target'],
  748. 'menu-item-classes' => $value['classes'],
  749. 'menu-item-xfn' => $value['xfn'],
  750. 'menu-item-status' => $value['status'],
  751. );
  752. $r = wp_update_nav_menu_item(
  753. $value['nav_menu_term_id'],
  754. $is_placeholder ? 0 : $this->post_id,
  755. wp_slash( $menu_item_data )
  756. );
  757. if ( is_wp_error( $r ) ) {
  758. $this->update_status = 'error';
  759. $this->update_error = $r;
  760. } else {
  761. if ( $is_placeholder ) {
  762. $this->previous_post_id = $this->post_id;
  763. $this->post_id = $r;
  764. $this->update_status = 'inserted';
  765. } else {
  766. $this->update_status = 'updated';
  767. }
  768. }
  769. }
  770. }
  771. /**
  772. * Export data for the JS client.
  773. *
  774. * @since 4.3.0
  775. *
  776. * @see WP_Customize_Nav_Menu_Item_Setting::update()
  777. *
  778. * @param array $data Additional information passed back to the 'saved' event on `wp.customize`.
  779. * @return array Save response data.
  780. */
  781. public function amend_customize_save_response( $data ) {
  782. if ( ! isset( $data['nav_menu_item_updates'] ) ) {
  783. $data['nav_menu_item_updates'] = array();
  784. }
  785. $data['nav_menu_item_updates'][] = array(
  786. 'post_id' => $this->post_id,
  787. 'previous_post_id' => $this->previous_post_id,
  788. 'error' => $this->update_error ? $this->update_error->get_error_code() : null,
  789. 'status' => $this->update_status,
  790. );
  791. return $data;
  792. }
  793. }