function update( WP_REST_Request $request ) { $post_id = (int) $request->get_param( 'post_id' ); $post_status = $request->get_param( 'post_status' ); $content = $request->get_param( 'content' ); $preferences = $request->get_param( 'preferences' ); $page_settings_by_layout = $request->get_param( 'pageSettingsByLayout' ); $sync_type = $request->get_param( 'syncType' ); $options = $request->get_param( 'options' ); $workspace = $request->get_param( 'workspace' ); $prime_cache_url = $request->get_param( 'prime_cache_url' ); $main_loop_type = $request->get_param( 'mainLoopType' ); $main_loop_type = is_string( $main_loop_type ) ? $main_loop_type : 'singular'; $loop_settings_data = $request->get_param( 'mainLoopSettingsData' ); $layout_post_ids = $request->get_param( 'layout_post_ids' ); $layout_post_ids = is_array( $layout_post_ids ) ? $layout_post_ids : []; $layout_post_ids = self::sanitize_layout_post_ids( $layout_post_ids ); $page_settings_by_layout = is_array( $page_settings_by_layout ) ? $page_settings_by_layout : []; $layout_post_ids = self::_validate_and_normalize_layout_post_ids( $layout_post_ids, $post_id, $main_loop_type ); if ( is_wp_error( $layout_post_ids ) ) { return self::response_error( $layout_post_ids->get_error_code(), $layout_post_ids->get_error_message(), $layout_post_ids->get_error_data() ); } foreach ( $page_settings_by_layout as $layout => $layout_page_settings ) { if ( ! is_array( $layout_page_settings ) || ! isset( $layout_page_settings['customCss'] ) ) { continue; } $page_settings_by_layout[ $layout ]['customCss'] = str_replace( '\\', '\\\\', $layout_page_settings['customCss'] ); } $post_content = isset( $content['post_content'] ) ? $content['post_content'] : ''; $header_content = isset( $content['header'] ) ? $content['header'] : ''; $body_content = isset( $content['body'] ) ? $content['body'] : ''; $footer_content = isset( $content['footer'] ) ? $content['footer'] : ''; $has_post_content_payload = '' !== $post_content; $post_content_post_id = isset( $layout_post_ids['postContent'] ) ? absint( $layout_post_ids['postContent'] ) : $post_id; $header_post_id = isset( $layout_post_ids['header'] ) ? absint( $layout_post_ids['header'] ) : 0; $body_post_id = isset( $layout_post_ids['body'] ) ? absint( $layout_post_ids['body'] ) : 0; $footer_post_id = isset( $layout_post_ids['footer'] ) ? absint( $layout_post_ids['footer'] ) : 0; $post_content_page_settings = isset( $page_settings_by_layout['postContent'] ) && is_array( $page_settings_by_layout['postContent'] ) ? $page_settings_by_layout['postContent'] : []; // Guardrail: never allow empty placeholder-only payloads to overwrite Theme Builder layouts. $header_has_content = self::_has_meaningful_layout_content( $header_content ); $body_has_content = self::_has_meaningful_layout_content( $body_content ); $footer_has_content = self::_has_meaningful_layout_content( $footer_content ); if ( ! $header_has_content ) { $header_post_id = 0; } if ( ! $body_has_content ) { $body_post_id = 0; } if ( ! $footer_has_content ) { $footer_post_id = 0; } // Extract off-canvas data and make it available for hooks. $off_canvas_data = $request->get_param( 'off_canvas_data' ); if ( $off_canvas_data ) { // Normalize to array format - decode JSON string if needed. $normalized_canvas_data = self::_normalize_off_canvas_data( $off_canvas_data ); if ( $normalized_canvas_data ) { // Store off-canvas data globally so hooks can access it. $GLOBALS['divi_off_canvas_data'] = $normalized_canvas_data; } } // Check if there is an autosave that is newer. $post_author = get_current_user_id(); // Store one autosave per author. If there is already an autosave, overwrite it. $autosave = wp_get_post_autosave( $post_id, $post_author ); if ( ! empty( $autosave ) ) { wp_delete_post_revision( $autosave->ID ); } // Check if what is being synced to server is preview post. $is_saving_preview = 'preview' === $sync_type; // Preview post aims to have similar post previewing experience as WordPress block editor. // How preview post works in WordPress block editor: // 1. Preview button is clicked. // 2. REST request is sent to `/wp-json/wp/v2/pages/POST_ID/autosaves?_locale=user` endpoint. // 3. An autosave post for current post is created. Autosave post is another record on `wp_post` table that: // - has post_status: `inherit` // - has post_type: `revision` // - has post_parent pointing to the published / draft post ID // 4. Basically the autosave post is the most recent revision of the post. A user commonly can have only have // one autosave per post. // 5. Once autosave post is created, the preview post URL is generated and returned to the client. // 6. User is being redirected to the preview post URL // For WordPress block editor's preview post mechanism, see: `WP_REST_Autosaves_Controller->create_item()`. if ( $is_saving_preview ) { // Create new autosave as post revision that will be used for preview. $revision_id = Revision::put_post_revision( [ 'ID' => $post_id, 'post_title' => $post_content_page_settings['postTitle'] ?? '', // `_wp_put_post_revision()` already does the `wp_slash()` for the content. No need apply `wp_slash()` here. 'post_content' => $post_content, ], true ); // Create preview post link. // Equivalent of this on WordPress block editor can be seen at WP_REST_Autosaves_Controller->prepare_item_for_response(). $preview_url = get_preview_post_link( $post_id, [ 'preview_id' => $post_id, 'preview_nonce' => wp_create_nonce( 'post_preview_' . $post_id ), ] ); return self::response_success( [ 'previewId' => $revision_id, 'previewUrl' => $preview_url, 'syncType' => 'preview', 'post_status' => $post_status, 'save_verification' => $revision_id ? true : false, ] ); } /** * Filters whether to proceed with the post save operation. * * This filter allows modules to intercept the save process and prevent * the default wp_update_post() call. If the filter returns an array, * that array will be returned as the success response and no save will occur. * If the filter returns null, the save will proceed normally. * * @since ?? * * @param array|null $abort_save_response Response array to return if save should be aborted, or null to proceed. * @param int $post_id The post ID being saved. * @param string $post_content The post content being saved. * @param string $post_status The post status being saved. * @param array $options The options array containing conditional_tags and other metadata. * @param array $post_content_page_settings The post content page settings array (if any). */ $abort_save_response = apply_filters( 'divi_visual_builder_rest_before_save_post', null, $post_id, $post_content, $post_status, $options, $post_content_page_settings ); if ( is_array( $abort_save_response ) ) { // A filter has intercepted the save and provided a custom response. return self::response_success( $abort_save_response ); } // Build the post update array. $post_update_data = [ 'ID' => $post_id, 'post_status' => $post_status, ]; // Ignore invalid empty-string post content payloads, while still allowing status/title/excerpt updates. if ( $has_post_content_payload ) { $post_update_data['post_content'] = wp_slash( $post_content ); } // Include post_title and post_excerpt in this update to avoid a second wp_update_post() call. // This prevents duplicate revisions and duplicate filter processing. // Note: wp_update_post() internally calls sanitize_post() which sanitizes both fields, // but we sanitize here for consistency with the existing approach in et_builder_update_settings(). if ( $post_content_page_settings ) { if ( isset( $post_content_page_settings['postTitle'] ) ) { $post_update_data['post_title'] = sanitize_text_field( $post_content_page_settings['postTitle'] ); } if ( isset( $post_content_page_settings['postExcerpt'] ) ) { // Use wp_kses_post for excerpt to allow certain HTML tags (same as et_builder_update_settings). $post_update_data['post_excerpt'] = wp_kses_post( $post_content_page_settings['postExcerpt'] ); } } $main_post = get_post( $post_id ); $has_main_post_context = $main_post instanceof \WP_Post; $update = $has_main_post_context ? wp_update_post( $post_update_data ) : 0; $layout_updates = [ [ 'layout' => 'postContent', 'id' => $post_content_post_id, 'content' => $post_content, ], [ 'layout' => 'header', 'id' => $header_post_id, 'content' => $header_content, ], [ 'layout' => 'body', 'id' => $body_post_id, 'content' => $body_content, ], [ 'layout' => 'footer', 'id' => $footer_post_id, 'content' => $footer_content, ], ]; $updated_theme_builder_layout_post_ids = []; foreach ( $layout_updates as $layout_update ) { $target_layout = $layout_update['layout'] ?? 'postContent'; $target_post_id = absint( $layout_update['id'] ?? 0 ); $target_content = $layout_update['content'] ?? ''; // Main post content is already updated above. if ( $post_id === $target_post_id ) { continue; } $layout_update_result = self::_update_layout_post_content( $target_post_id, $target_content ); if ( is_wp_error( $layout_update_result ) ) { return self::response_error( $layout_update_result->get_error_code(), $layout_update_result->get_error_message(), $layout_update_result->get_error_data() ); } if ( 0 !== (int) $layout_update_result && in_array( $target_layout, [ 'header', 'body', 'footer' ], true ) ) { $updated_theme_builder_layout_post_ids[] = $target_post_id; } } // Ensure Theme Builder layout saves clear template-targeted frontend caches. // In non-singular Visual Builder contexts, the main request post ID is not a layout post, // so cache clearing must also run for explicitly saved layout post IDs. if ( ! empty( $updated_theme_builder_layout_post_ids ) ) { foreach ( array_unique( $updated_theme_builder_layout_post_ids ) as $updated_layout_post_id ) { et_theme_builder_clear_wp_post_cache( $updated_layout_post_id ); } } // Save page settings by layout post ID. if ( ! empty( $page_settings_by_layout ) ) { self::_save_page_settings_by_layout( $page_settings_by_layout, [ 'postContent' => $post_content_post_id, 'header' => $header_post_id, 'body' => $body_post_id, 'footer' => $footer_post_id, ], $post_id ); } // Save non-singular page settings (term properties, author properties, etc.). // The frontend syncs field edits back into `mainLoopSettingsData`, so the // updated values arrive here directly — no separate property needed. if ( 'singular' !== $main_loop_type && is_array( $loop_settings_data ) ) { $expected_main_loop_owner_context = self::_get_expected_main_loop_owner_context( $main_loop_type ); SavingUtility::save_non_singular_page_settings( $loop_settings_data, $main_loop_type, $expected_main_loop_owner_context ); } /** * Action hook to fire when the Post is being saved. * * This is for backward compatibility with hooks written for Divi version <5.0.0. * * @since 3.20 * @deprecated 5.0.0 Use `divi_visual_builder_rest_save_post` hook instead. * * @param int $post_id Post ID. */ do_action( 'et_save_post', $post_id ); /** * Action hook to fire when the Post is being saved. * * @since ?? * * @param int $post_id Post ID. */ // Pass request context into save hooks. // This avoids relying on globals and is extendable for future non-singular owners. do_action( 'divi_visual_builder_rest_save_post', $post_id, [ 'mainLoopType' => $main_loop_type, 'mainLoopSettingsData' => $loop_settings_data, ] ); // Prime page cache AFTER off-canvas data and other post meta is saved. // This ensures that when the cache priming request triggers early detection, // all post meta (including appended canvas content) is available. SavingUtility::prime_page_cache_on_save( $post_id, $prime_cache_url ); if ( ( $update && ! is_wp_error( $update ) ) || ( ! $has_main_post_context && ! is_wp_error( $update ) ) ) { // Update post meta so we know D5 is used and Readiness migrator will skip it. if ( $has_main_post_context ) { update_post_meta( $post_id, '_et_pb_use_divi_5', 'on' ); // Also set the legacy meta for backward compatibility with D4 components. update_post_meta( $post_id, '_et_pb_use_builder', 'on' ); // Clear the page creation flow flag so it doesn't show again on subsequent loads. update_post_meta( $post_id, '_et_pb_show_page_creation', 'off' ); } $layout_ids_to_mark = [ $post_content_post_id, $header_post_id, $body_post_id, $footer_post_id, ]; foreach ( $layout_ids_to_mark as $layout_id ) { $layout_id = absint( $layout_id ); if ( 0 === $layout_id || $post_id === $layout_id ) { continue; } update_post_meta( $layout_id, '_et_pb_use_divi_5', 'on' ); update_post_meta( $layout_id, '_et_pb_use_builder', 'on' ); } $preferences = SavingUtility::sanitize_app_preferences( $preferences ); foreach ( $preferences as $preference_key => $preference_value ) { $option_name = 'et_fb_pref_' . $preference_key; et_update_option( $option_name, $preference_value ); } // Updated last used workspace. $last_used_workspace = $workspace['last-used'] ?? []; Workspace::update_item( 'builtIn', 'last-used', $last_used_workspace ); /** * Action hook to fire when the Post is updated. * * This is for backward compatibility with hooks written for Divi version <5.0.0. * * @param int $post_id Post ID. * * @since 3.29 * @deprecated 5.0.0 Use `divi_visual_builder_rest_update_post` hook instead. */ do_action( 'et_update_post', $post_id ); /** * Action hook to fire when the Post is updated. * * @param int $post_id Post ID. * * @since ?? */ do_action( 'divi_visual_builder_rest_update_post', $post_id ); $saved_post_content = $has_main_post_context ? get_post_field( 'post_content', $update ) : $post_content; $verification = $has_main_post_context ? self::_verify_post_content_matches_after_sanitization( $post_content, $saved_post_content ) : true; /** * Filter to modify the save verification result. * * @since ?? * @deprecated 5.0.0 Use the {@see 'divi_visual_builder_rest_save_verification'} filter instead. * * @param bool $verification Whether to save the verification result. */ $verification = apply_filters( 'et_fb_ajax_save_verification_result', $verification ); /** * Filter to modify the save verification result. * * @since ?? * * @param bool $verification Whether to save the verification result. */ $save_verification_filtered = apply_filters( 'divi_visual_builder_rest_save_post_save_verification', $verification ); $return_rendered_content = $options['return_rendered_content'] ?? false; if ( $return_rendered_content && $has_main_post_context ) { // Replace dynamic data in the content with the actual value. $normalized_content = DynamicData::get_processed_dynamic_data( $saved_post_content, $post_id, true ); // Apply the_content filter to the content. $rendered_content = apply_filters( 'the_content', $normalized_content ); } else { $rendered_content = $saved_post_content; } return self::response_success( [ 'post_status' => $has_main_post_context ? get_post_status( $update ) : $post_status, 'save_verification' => $save_verification_filtered, 'rendered_content' => $rendered_content, ] ); } if ( is_wp_error( $update ) ) { return self::response_error( $update->get_error_code(), $update->get_error_message(), $update->get_error_data() ); } return self::response_error( 'unknown_error', esc_html__( 'Unknown error.', 'et_builder_5' ) ); }