: str_replace(): Passing null to parameter #2 ($replace) of type array|string is deprecated in
$response['changeset_post_save_failure'] = $r->get_error_code();
return new WP_Error( 'changeset_post_save_failure', '', $response );
* Preserves the initial JSON post_content passed to save into the post.
* This is needed to prevent KSES and other {@see 'content_save_pre'} filters
* from corrupting JSON data.
* Note that WP_Customize_Manager::validate_setting_values() have already
* run on the setting values being serialized as JSON into the post content
* so it is pre-sanitized.
* Also, the sanitization logic is re-run through the respective
* WP_Customize_Setting::sanitize() method when being read out of the
* changeset, via WP_Customize_Manager::post_value(), and this sanitized
* value will also be sent into WP_Customize_Setting::update() for
* Multiple users can collaborate on a single changeset, where one user may
* have the unfiltered_html capability but another may not. A user with
* unfiltered_html may add a script tag to some field which needs to be kept
* intact even when another user updates the changeset to modify another field
* when they do not have unfiltered_html.
* @param array $data An array of slashed and processed post data.
* @param array $postarr An array of sanitized (and slashed) but otherwise unmodified post data.
* @param array $unsanitized_postarr An array of slashed yet *unsanitized* and unprocessed post data as originally passed to wp_insert_post().
* @return array Filtered post data.
public function preserve_insert_changeset_post_content( $data, $postarr, $unsanitized_postarr ) {
isset( $data['post_type'] ) &&
isset( $unsanitized_postarr['post_content'] ) &&
'customize_changeset' === $data['post_type'] ||
'revision' === $data['post_type'] &&
! empty( $data['post_parent'] ) &&
'customize_changeset' === get_post_type( $data['post_parent'] )
$data['post_content'] = $unsanitized_postarr['post_content'];
* Trashes or deletes a changeset post.
* The following re-formulates the logic from `wp_trash_post()` as done in
* `wp_publish_post()`. The reason for bypassing `wp_trash_post()` is that it
* will mutate the the `post_content` and the `post_name` when they should be
* @global wpdb $wpdb WordPress database abstraction object.
* @param int|WP_Post $post The changeset post.
* @return mixed A WP_Post object for the trashed post or an empty value on failure.
public function trash_changeset_post( $post ) {
$post = get_post( $post );
if ( ! ( $post instanceof WP_Post ) ) {
if ( ! EMPTY_TRASH_DAYS ) {
return wp_delete_post( $post_id, true );
if ( 'trash' === get_post_status( $post ) ) {
$previous_status = $post->post_status;
/** This filter is documented in wp-includes/post.php */
$check = apply_filters( 'pre_trash_post', null, $post, $previous_status );
/** This action is documented in wp-includes/post.php */
do_action( 'wp_trash_post', $post_id, $previous_status );
add_post_meta( $post_id, '_wp_trash_meta_status', $previous_status );
add_post_meta( $post_id, '_wp_trash_meta_time', time() );
$wpdb->update( $wpdb->posts, array( 'post_status' => $new_status ), array( 'ID' => $post->ID ) );
clean_post_cache( $post->ID );
$post->post_status = $new_status;
wp_transition_post_status( $new_status, $previous_status, $post );
/** This action is documented in wp-includes/post.php */
do_action( "edit_post_{$post->post_type}", $post->ID, $post );
/** This action is documented in wp-includes/post.php */
do_action( 'edit_post', $post->ID, $post );
/** This action is documented in wp-includes/post.php */
do_action( "save_post_{$post->post_type}", $post->ID, $post, true );
/** This action is documented in wp-includes/post.php */
do_action( 'save_post', $post->ID, $post, true );
/** This action is documented in wp-includes/post.php */
do_action( 'wp_insert_post', $post->ID, $post, true );
wp_after_insert_post( get_post( $post_id ), true, $post );
wp_trash_post_comments( $post_id );
/** This action is documented in wp-includes/post.php */
do_action( 'trashed_post', $post_id, $previous_status );
* Handles request to trash a changeset.
public function handle_changeset_trash_request() {
if ( ! is_user_logged_in() ) {
wp_send_json_error( 'unauthenticated' );
if ( ! $this->is_preview() ) {
wp_send_json_error( 'not_preview' );
if ( ! check_ajax_referer( 'trash_customize_changeset', 'nonce', false ) ) {
'code' => 'invalid_nonce',
'message' => __( 'There was an authentication problem. Please reload and try again.' ),
$changeset_post_id = $this->changeset_post_id();
if ( ! $changeset_post_id ) {
'message' => __( 'No changes saved yet, so there is nothing to trash.' ),
'code' => 'non_existent_changeset',
if ( $changeset_post_id ) {
if ( ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->delete_post, $changeset_post_id ) ) {
'code' => 'changeset_trash_unauthorized',
'message' => __( 'Unable to trash changes.' ),
$lock_user = (int) wp_check_post_lock( $changeset_post_id );
if ( $lock_user && get_current_user_id() !== $lock_user ) {
'code' => 'changeset_locked',
'message' => __( 'Changeset is being edited by other user.' ),
'lockUser' => $this->get_lock_user_data( $lock_user ),
if ( 'trash' === get_post_status( $changeset_post_id ) ) {
'message' => __( 'Changes have already been trashed.' ),
'code' => 'changeset_already_trashed',
$r = $this->trash_changeset_post( $changeset_post_id );
if ( ! ( $r instanceof WP_Post ) ) {
'code' => 'changeset_trash_failure',
'message' => __( 'Unable to trash changes.' ),
'message' => __( 'Changes trashed successfully.' ),
* Re-maps 'edit_post' meta cap for a customize_changeset post to be the same as 'customize' maps.
* There is essentially a "meta meta" cap in play here, where 'edit_post' meta cap maps to
* the 'customize' meta cap which then maps to 'edit_theme_options'. This is currently
* required in core for `wp_create_post_autosave()` because it will call
* `_wp_translate_postdata()` which in turn will check if a user can 'edit_post', but the
* the caps for the customize_changeset post type are all mapping to the meta capability.
* This should be able to be removed once #40922 is addressed in core.
* @link https://core.trac.wordpress.org/ticket/40922
* @see WP_Customize_Manager::save_changeset_post()
* @see _wp_translate_postdata()
* @param string[] $caps Array of the user's capabilities.
* @param string $cap Capability name.
* @param int $user_id The user ID.
* @param array $args Adds the context to the cap. Typically the object ID.
* @return array Capabilities.
public function grant_edit_post_capability_for_changeset( $caps, $cap, $user_id, $args ) {
if ( 'edit_post' === $cap && ! empty( $args[0] ) && 'customize_changeset' === get_post_type( $args[0] ) ) {
$post_type_obj = get_post_type_object( 'customize_changeset' );
$caps = map_meta_cap( $post_type_obj->cap->$cap, $user_id );
* Marks the changeset post as being currently edited by the current user.
* @param int $changeset_post_id Changeset post ID.
* @param bool $take_over Whether to take over the changeset. Default false.
public function set_changeset_lock( $changeset_post_id, $take_over = false ) {
if ( $changeset_post_id ) {
$can_override = ! (bool) get_post_meta( $changeset_post_id, '_edit_lock', true );
$lock = sprintf( '%s:%s', time(), get_current_user_id() );
update_post_meta( $changeset_post_id, '_edit_lock', $lock );
$this->refresh_changeset_lock( $changeset_post_id );
* Refreshes changeset lock with the current time if current user edited the changeset before.
* @param int $changeset_post_id Changeset post ID.
public function refresh_changeset_lock( $changeset_post_id ) {
if ( ! $changeset_post_id ) {
$lock = get_post_meta( $changeset_post_id, '_edit_lock', true );
$lock = explode( ':', $lock );
if ( $lock && ! empty( $lock[1] ) ) {
$user_id = (int) $lock[1];
$current_user_id = get_current_user_id();
if ( $user_id === $current_user_id ) {
$lock = sprintf( '%s:%s', time(), $user_id );
update_post_meta( $changeset_post_id, '_edit_lock', $lock );
* Filters heartbeat settings for the Customizer.
* @global string $pagenow The filename of the current screen.
* @param array $settings Current settings to filter.
* @return array Heartbeat settings.
public function add_customize_screen_to_heartbeat_settings( $settings ) {
if ( 'customize.php' === $pagenow ) {
$settings['screenId'] = 'customize';
* @param int $user_id User ID.
* @return array|null User data formatted for client.
protected function get_lock_user_data( $user_id ) {
$lock_user = get_userdata( $user_id );
'name' => $lock_user->display_name,
'avatar' => get_avatar_url( $lock_user->ID, array( 'size' => 128 ) ),
* Checks locked changeset with heartbeat API.
* @param array $response The Heartbeat response.
* @param array $data The $_POST data sent.
* @param string $screen_id The screen id.
* @return array The Heartbeat response.
public function check_changeset_lock_with_heartbeat( $response, $data, $screen_id ) {
if ( isset( $data['changeset_uuid'] ) ) {
$changeset_post_id = $this->find_changeset_post_id( $data['changeset_uuid'] );
$changeset_post_id = $this->changeset_post_id();
array_key_exists( 'check_changeset_lock', $data )
&& 'customize' === $screen_id
&& current_user_can( get_post_type_object( 'customize_changeset' )->cap->edit_post, $changeset_post_id )
$lock_user_id = wp_check_post_lock( $changeset_post_id );
$response['customize_changeset_lock_user'] = $this->get_lock_user_data( $lock_user_id );
// Refreshing time will ensure that the user is sitting on customizer and has not closed the customizer tab.
$this->refresh_changeset_lock( $changeset_post_id );
* Removes changeset lock when take over request is sent via Ajax.
public function handle_override_changeset_lock_request() {
if ( ! $this->is_preview() ) {
wp_send_json_error( 'not_preview', 400 );
if ( ! check_ajax_referer( 'customize_override_changeset_lock', 'nonce', false ) ) {
'code' => 'invalid_nonce',
'message' => __( 'Security check failed.' ),
$changeset_post_id = $this->changeset_post_id();
if ( empty( $changeset_post_id ) ) {
'code' => 'no_changeset_found_to_take_over',
'message' => __( 'No changeset found to take over' ),
if ( ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->edit_post, $changeset_post_id ) ) {
'code' => 'cannot_remove_changeset_lock',
'message' => __( 'Sorry, you are not allowed to take over.' ),
$this->set_changeset_lock( $changeset_post_id, true );
wp_send_json_success( 'changeset_taken_over' );
* Determines whether a changeset revision should be made.
protected $store_changeset_revision;
* Filters whether a changeset has changed to create a new revision.
* Note that this will not be called while a changeset post remains in auto-draft status.
* @param bool $post_has_changed Whether the post has changed.
* @param WP_Post $latest_revision The latest revision post object.
* @param WP_Post $post The post object.
* @return bool Whether a revision should be made.
public function _filter_revision_post_has_changed( $post_has_changed, $latest_revision, $post ) {
unset( $latest_revision );
if ( 'customize_changeset' === $post->post_type ) {
$post_has_changed = $this->store_changeset_revision;
return $post_has_changed;
* Publishes the values of a changeset.
* This will publish the values contained in a changeset, even changesets that do not
* correspond to current manager instance. This is called by
* `_wp_customize_publish_changeset()` when a customize_changeset post is
* transitioned to the `publish` status. As such, this method should not be
* called directly and instead `wp_publish_post()` should be used.
* Please note that if the settings in the changeset are for a non-activated
* theme, the theme must first be switched to (via `switch_theme()`) before
* @see _wp_customize_publish_changeset()
* @global wpdb $wpdb WordPress database abstraction object.
* @param int $changeset_post_id ID for customize_changeset post. Defaults to the changeset for the current manager instance.
* @return true|WP_Error True or error info.
public function _publish_changeset_values( $changeset_post_id ) {
$publishing_changeset_data = $this->get_changeset_post_data( $changeset_post_id );
if ( is_wp_error( $publishing_changeset_data ) ) {
return $publishing_changeset_data;
$changeset_post = get_post( $changeset_post_id );
* Temporarily override the changeset context so that it will be read
* in calls to unsanitized_post_values() and so that it will be available
* on the $wp_customize object passed to hooks during the save logic.
$previous_changeset_post_id = $this->_changeset_post_id;
$this->_changeset_post_id = $changeset_post_id;
$previous_changeset_uuid = $this->_changeset_uuid;
$this->_changeset_uuid = $changeset_post->post_name;
$previous_changeset_data = $this->_changeset_data;
$this->_changeset_data = $publishing_changeset_data;
// Parse changeset data to identify theme mod settings and user IDs associated with settings to be saved.
$setting_user_ids = array();
$theme_mod_settings = array();
$namespace_pattern = '/^(?P<stylesheet>.+?)::(?P<setting_id>.+)$/';