function sync( WP_REST_Request $request ) { $is_chunked = $request->get_param( 'isChunked' ); $chunk_index = $request->get_param( 'chunkIndex' ); $total_chunks = $request->get_param( 'totalChunks' ); $is_last_chunk = $request->get_param( 'isLastChunk' ); $request_id = $request->get_param( 'requestId' ); $action_type = $request->get_param( 'actionType' ) ?? ''; // Validate chunked request: if isChunked is true, all chunking parameters must be present. if ( true === $is_chunked ) { if ( null === $chunk_index || null === $total_chunks || null === $is_last_chunk || null === $request_id ) { return RESTController::response_error( 'invalid_chunked_request', esc_html__( 'Invalid chunked request: missing required chunking parameters.', 'et_builder_5' ), [ 'status' => 400, ] ); } } // Validate non-chunked request: if isChunked is false (or null/defaults to false), chunking parameters must not be present. if ( false === $is_chunked || null === $is_chunked ) { if ( null !== $chunk_index || null !== $total_chunks || null !== $is_last_chunk || null !== $request_id ) { return RESTController::response_error( 'invalid_non_chunked_request', esc_html__( 'Invalid non-chunked request: chunking parameters should not be present.', 'et_builder_5' ), [ 'status' => 400, ] ); } } // Handle chunked requests: accumulate chunks before processing. // Check explicit isChunked flag first, then verify all required chunking parameters are present. if ( true === $is_chunked ) { // Get transient key for this request ID. $transient_key = self::_get_chunk_transient_key( $request_id ); // Get existing accumulated chunks from transient. $accumulated_data = get_transient( $transient_key ); // Initialize accumulation for this request ID if not exists. if ( false === $accumulated_data ) { $accumulated_data = [ 'chunks' => [], 'actionType' => $action_type, 'totalChunks' => $total_chunks, ]; } // Store this chunk. $chunk_presets = $request->get_param( 'presets' ); $accumulated_data['chunks'][ $chunk_index ] = $chunk_presets; // Update transient with new chunk data. set_transient( $transient_key, $accumulated_data, self::CHUNK_TRANSIENT_EXPIRATION ); // If this is not the last chunk, return early with success (chunk received). if ( ! $is_last_chunk ) { return RESTController::response_success( (object) [] ); } // This is the last chunk - verify we received all expected chunks before merging. $accumulated_chunks = $accumulated_data['chunks']; $received_chunk_count = count( $accumulated_chunks ); // Verify we have all chunks (chunk indices are 0-based, so total should match). if ( $received_chunk_count !== $total_chunks ) { // Clean up transient on error. delete_transient( $transient_key ); return RESTController::response_error( 'incomplete_chunks', sprintf( // translators: %1$d: received chunks, %2$d: expected chunks. esc_html__( 'Incomplete chunk data received. Expected %1$d chunks, received %2$d chunks.', 'et_builder_5' ), $total_chunks, $received_chunk_count ), [ 'status' => 400, 'expectedChunks' => $total_chunks, 'receivedChunks' => $received_chunk_count, 'receivedIndices' => array_keys( $accumulated_chunks ), ] ); } // Merge all chunks and process. $merged_presets = self::_merge_chunks( $accumulated_chunks ); // Clean up transient after successful merge. delete_transient( $transient_key ); // Use merged presets for processing. $prepared_data = GlobalPreset::prepare_data( $merged_presets ); } else { // Non-chunked request - process normally. $prepared_data = GlobalPreset::prepare_data( $request->get_param( 'presets' ) ); } // CRITICAL SAFETY CHECK: Validate that we're not accidentally losing presets during sync. // Only DELETE actions should reduce the preset count. All other operations (save, add, update) // should preserve or increase the preset count. // This prevents bugs where incomplete restore points or data corruption causes preset loss. // We compare against the current database state (source of truth), not client-side state. $current_presets = GlobalPreset::get_data(); $validation_error = GlobalPreset::validate_preset_count( $current_presets, $prepared_data, $action_type ); if ( is_wp_error( $validation_error ) ) { // Clean up transient on validation error. if ( null !== $request_id ) { $transient_key = self::_get_chunk_transient_key( $request_id ); delete_transient( $transient_key ); } return $validation_error; } // Handle deletion cleanup for D4 legacy presets. // When a preset is deleted in D5, we need to also remove it from the D4 legacy option // to prevent re-migration on page refresh. if ( 'DELETE_MODULE_PRESET' === $action_type || 'DELETE_OPTION_GROUP_PRESET' === $action_type ) { $preset_type = 'DELETE_MODULE_PRESET' === $action_type ? 'module' : 'group'; // Get deleted preset IDs by comparing current (before deletion) vs incoming (after deletion). $deleted_presets = GlobalPreset::get_deleted_preset_ids( $current_presets, $prepared_data, $preset_type ); // Remove each deleted preset from the D4 legacy option. foreach ( $deleted_presets as $deleted_preset ) { GlobalPreset::remove_preset_from_legacy_option( $deleted_preset['id'], $deleted_preset['moduleName'] ); } } $saved_data = GlobalPreset::save_data( $prepared_data ); return RESTController::response_success( (object) $saved_data ); }