PluginProbe
Elementor Website Builder – more than just a page builder / 4.3.1
Elementor Website Builder – more than just a page builder v4.3.1
4.3.1 4.3.0 4.3.0-beta3 4.3.0-beta2 4.3.0-beta1 4.2.4 4.2.3 4.2.2 4.2.1 4.2.0 4.1.5 4.2.0-beta2 4.2.0-dev2 4.2.0-beta1 4.1.4 4.1.3 4.1.2 4.1.1 4.1.0 4.1.0-beta3 4.1.0-dev3 4.0.9 4.1.0-beta2 4.1.0-dev2 4.0.8 All 454 releases
← All changes | modules/mcp/abilities/manage-elements-ability.php +354 -46 4.3.0-beta1 → 4.3.1 View file →
@@ -22,9 +22,12 @@
22 22 use Elementor\Modules\Mcp\Abilities\Appliers\Style_Applier;
23 23 use Elementor\Modules\Mcp\Abilities\Build_Composition\Widget_Type_Resolver;
24 24 use Elementor\Modules\Mcp\Abilities\Build_Composition\Xml_Parser;
25 25 use Elementor\Modules\Mcp\Abilities\Utils\Bulk_Operations_Result;
26 +use Elementor\Modules\Mcp\Abilities\Utils\Document_Mutation_Save;
27 +use Elementor\Modules\Mcp\Abilities\Utils\Tool_Performance_Metrics;
26 28 use Elementor\Modules\Mcp\Abilities\Utils\Widget_Context_Helper;
29 +use Elementor\Modules\Mcp\Events\Mcp_Event_Dispatcher;
27 30 use Elementor\Modules\Variables\Module as Variables_Module;
28 31 use Elementor\Modules\Variables\Services\Batch_Operations\Batch_Processor;
29 32 use Elementor\Modules\Variables\Services\Variables_Service;
30 33 use Elementor\Modules\Variables\Storage\Variables_Repository;
@@ -137,25 +140,33 @@
137 140 );
138 141 }
139 142
140 143 public function execute( $input = [] ) {
141 - $input = is_array( $input ) ? $input : [];
144 + $started_at = hrtime( true );
145 + $input = is_array( $input ) ? $input : [];
146 + $post_id = isset( $input['post_id'] ) ? (int) $input['post_id'] : 0;
147 + $operations = $input['operations'] ?? null;
142 148
143 149 if ( empty( $input['post_id'] ) ) {
144 - return $this->bad_request( __( 'post_id is required.', 'elementor' ) );
150 + $error = $this->bad_request( __( 'post_id is required.', 'elementor' ) );
151 + $this->emit_mcp_manage_elements_executed( $started_at, $post_id, null, $error );
152 + return $error;
145 153 }
146 154
147 - $operations = $input['operations'] ?? null;
148 155 if ( ! is_array( $operations ) ) {
149 - return $this->bad_request( __( 'operations array is required.', 'elementor' ) );
156 + $error = $this->bad_request( __( 'operations array is required.', 'elementor' ) );
157 + $this->emit_mcp_manage_elements_executed( $started_at, $post_id, null, $error );
158 + return $error;
150 159 }
151 160
152 161 if ( empty( $operations ) ) {
153 - return $this->bad_request( __( 'operations must not be empty.', 'elementor' ) );
162 + $error = $this->bad_request( __( 'operations must not be empty.', 'elementor' ) );
163 + $this->emit_mcp_manage_elements_executed( $started_at, $post_id, null, $error );
164 + return $error;
154 165 }
155 166
156 167 if ( count( $operations ) > self::MAX_BATCH_SIZE ) {
157 - return new \WP_Error(
168 + $error = new \WP_Error(
158 169 'batch_size_exceeded',
159 170 sprintf(
160 171 /* translators: %d: maximum operations per request */
161 172 __( 'Maximum %d operations per request.', 'elementor' ),
@@ -165,32 +176,39 @@
165 176 'status' => \WP_Http::BAD_REQUEST,
166 177 'max_allowed' => self::MAX_BATCH_SIZE,
167 178 ]
168 179 );
180 + $this->emit_mcp_manage_elements_executed( $started_at, $post_id, null, $error, $operations );
181 + return $error;
169 182 }
170 183
171 - $post_id = (int) $input['post_id'];
172 -
173 184 if ( ! current_user_can( 'edit_post', $post_id ) ) {
174 - return new \WP_Error(
185 + $error = new \WP_Error(
175 186 'elementor_forbidden',
176 187 __( 'You do not have permission to edit this post.', 'elementor' ),
177 188 [ 'status' => \WP_Http::FORBIDDEN ]
178 189 );
190 + $this->emit_mcp_manage_elements_executed( $started_at, $post_id, null, $error, $operations );
191 + return $error;
179 192 }
180 193
181 194 $document = $this->resolve_document( $post_id );
182 195 if ( is_wp_error( $document ) ) {
196 + $this->emit_mcp_manage_elements_executed( $started_at, $post_id, null, $document, $operations );
183 197 return $document;
184 198 }
185 199
186 - return $this->handle_bulk( $document, $operations );
200 + return $this->handle_bulk( $document, $operations, $started_at );
187 201 }
188 202
189 - private function handle_bulk( Document $document, array $operations ): array {
190 - $results = new Bulk_Operations_Result();
191 - $tree = $this->get_tree( $document );
192 - $any_change = false;
203 + private function handle_bulk( Document $document, array $operations, int $started_at ): array {
204 + $results = new Bulk_Operations_Result();
205 + $tree = $this->get_tree( $document );
206 + $any_change = false;
207 + $pending_events = [];
208 + $all_warning_codes = [];
209 + $class_attachments = 0;
210 + $interactions_count = 0;
193 211
194 212 foreach ( $operations as $index => $operation ) {
195 213 $index = (int) $index;
196 214
@@ -221,33 +239,46 @@
221 239 if ( ! empty( $outcome['warnings'] ) ) {
222 240 $extra['warnings'] = $outcome['warnings'];
223 241 }
224 242 $results->add_success( $index, $action, $extra );
243 +
244 + $all_warning_codes = array_merge( $all_warning_codes, $outcome['warning_codes'] ?? [] );
245 + $class_attachments += count( $outcome['event_metadata']['applied_classes'] ?? [] );
246 + $interactions_count += count( $outcome['event_metadata']['interactions_events'] ?? [] );
247 +
248 + if ( ! empty( $outcome['event_metadata'] ) ) {
249 + $pending_events[] = [ 'action' => $action ] + $outcome['event_metadata'];
250 + }
225 251 }
226 252
227 - $response = $results->to_array();
253 + $response = $results->to_array();
228 254 $response['post_id'] = (int) $document->get_main_id();
255 + $post_id = (int) $document->get_main_id();
229 256
230 257 if ( ! $any_change ) {
231 - return $this->with_edit_url( $response, $document );
258 + $response = $this->with_edit_url( $response, $document );
259 + $this->emit_mcp_manage_elements_executed( $started_at, $post_id, $document, null, $operations, $response, $all_warning_codes, $class_attachments, $interactions_count );
260 + return $response;
232 261 }
233 262
234 - $save_result = $this->get_mutator()->save_as_draft( $document, $tree );
235 - if ( is_wp_error( $save_result ) || ! $save_result ) {
263 + $save_result = Document_Mutation_Save::elements_preserving_live_status( $this->get_mutator(), $document, $tree );
264 + if ( is_wp_error( $save_result ) ) {
236 265 $response['status'] = 'error';
237 - $response['save_error'] = is_wp_error( $save_result )
238 - ? $save_result->get_error_message()
239 - : __( 'Could not save document.', 'elementor' );
240 -
241 - return $this->with_edit_url( $response, $document );
266 + $response['save_error'] = $save_result->get_error_message();
267 + $response = $this->with_edit_url( $response, $document );
268 + $this->emit_mcp_manage_elements_executed( $started_at, $post_id, $document, $save_result, $operations, $response, $all_warning_codes, 0, 0 );
269 + return $response;
242 270 }
243 271
244 272 Plugin::$instance->files_manager->clear_cache();
245 273
246 - $post = get_post( $document->get_main_id() );
247 - $response['version'] = $post ? $post->post_modified_gmt : current_time( 'mysql', true );
274 + $this->emit_update_events( $pending_events, $tree );
248 275
249 - return $this->with_edit_url( $response, $document );
276 + $saved_post = $save_result->get_post();
277 + $response['version'] = $saved_post ? $saved_post->post_modified_gmt : current_time( 'mysql', true );
278 + $response = $this->with_edit_url( $response, $document );
279 + $this->emit_mcp_manage_elements_executed( $started_at, $post_id, $document, null, $operations, $response, $all_warning_codes, $class_attachments, $interactions_count );
280 + return $response;
250 281 }
251 282
252 283 private function with_edit_url( array $response, Document $document ): array {
253 284 $response['edit_url'] = $document->get_edit_url();
@@ -321,12 +352,14 @@
321 352
322 353 return [
323 354 'tree' => $new_tree,
324 355 'warnings' => [],
356 + 'warning_codes' => [],
325 357 ];
326 358 }
327 359
328 360 private function apply_duplicate( array $tree, string $element_id ) {
361 + $source_node = $this->get_mutator()->find_by_id( $tree, $element_id );
329 362 $new_tree = $this->get_mutator()->duplicate( $tree, $element_id );
330 363 if ( is_wp_error( $new_tree ) ) {
331 364 return $this->to_public_error( $new_tree );
332 365 }
@@ -333,11 +366,35 @@
333 366
334 367 return [
335 368 'tree' => $new_tree,
336 369 'warnings' => [],
370 + 'warning_codes' => [],
371 + 'event_metadata' => [
372 + 'duplicated_element_types' => is_array( $source_node ) ? $this->collect_element_types( $source_node ) : [],
373 + ],
337 374 ];
338 375 }
339 376
377 + private function collect_element_types( array $node ): array {
378 + $types = [];
379 + $stack = [ $node ];
380 +
381 + while ( ! empty( $stack ) ) {
382 + $current = array_pop( $stack );
383 + $type = $current['widgetType'] ?? $current['elType'] ?? '';
384 +
385 + if ( '' !== $type ) {
386 + $types[] = $type;
387 + }
388 +
389 + foreach ( $current['elements'] ?? [] as $child ) {
390 + $stack[] = $child;
391 + }
392 + }
393 +
394 + return $types;
395 + }
396 +
340 397 private function apply_move( array $tree, string $element_id, array $operation ) {
341 398 $new_parent_id = $operation['new_parent_id'] ?? '';
342 399 if ( ! is_string( $new_parent_id ) || '' === $new_parent_id ) {
343 400 return new \WP_Error( 'invalid_input', __( 'new_parent_id is required for action=move.', 'elementor' ) );
@@ -352,8 +409,9 @@
352 409
353 410 return [
354 411 'tree' => $new_tree,
355 412 'warnings' => [],
413 + 'warning_codes' => [],
356 414 ];
357 415 }
358 416
359 417 private function apply_update( Document $document, array $tree, string $element_id, array $operation ) {
@@ -396,11 +454,39 @@
396 454 return $widget_config;
397 455 }
398 456 $widget_configs = [ $element_type => $widget_config ];
399 457
400 - $variables_service = $this->create_variables_service();
401 - $warnings = [];
458 + $variables_service = $this->create_variables_service();
459 + $warnings = [];
460 + $warning_codes = [];
461 + $applied_classes = [];
462 + $variable_connections = [];
463 + $interactions_events = [];
402 464
465 + if ( null !== $interactions ) {
466 + if ( ! is_array( $interactions ) ) {
467 + return new \WP_Error( 'invalid_input', __( 'interactions must be an array of interaction items.', 'elementor' ) );
468 + }
469 + if ( ! Plugin::$instance->experiments->is_feature_active( Interactions_Module::EXPERIMENT_NAME ) ) {
470 + return new \WP_Error(
471 + 'elementor_invalid_interactions',
472 + __( 'Interactions experiment is not active. Interactions were not applied.', 'elementor' ),
473 + [ 'status' => \WP_Http::BAD_REQUEST ]
474 + );
475 + }
476 +
477 + $previous_items = $node_snapshot['interactions']['items'] ?? [];
478 +
479 + $interactions_applier = new Interactions_Applier( $this->get_plain_values_resolver() );
480 + $interactions_result = $interactions_applier->apply( $index, [ $element_id => $interactions ] );
481 + if ( $interactions_result['error'] ) {
482 + return $interactions_result['error'];
483 + }
484 + $warnings = array_merge( $warnings, $interactions_result['warnings'] );
485 +
486 + $interactions_events = $this->build_interactions_events( (string) $element_type, $previous_items, $interactions );
487 + }
488 +
403 489 if ( ! empty( $settings ) ) {
404 490 if ( Element_Config_Applier::COMPONENT_INSTANCE_WIDGET_TYPE === $element_type ) {
405 491 $component_applier = new Component_Instance_Applier( new Components_Repository(), $this->get_plain_values_resolver() );
406 492 $component_error = $component_applier->apply_partial( $index, [ $element_id => $settings ], $document );
@@ -417,9 +503,10 @@
417 503 );
418 504 if ( $config_result['error'] ) {
419 505 return $config_result['error'];
420 506 }
421 - $warnings = array_merge( $warnings, $config_result['warnings'] );
507 + $warnings = array_merge( $warnings, $config_result['warnings'] );
508 + $warning_codes = array_merge( $warning_codes, $config_result['warning_codes'] ?? [] );
422 509 }
423 510 }
424 511
425 512 if ( $has_classes ) {
@@ -430,8 +517,9 @@
430 517 $class_error = $class_applier->apply( $index, [ $element_id => $classes ] );
431 518 if ( $class_error ) {
432 519 return $class_error;
433 520 }
521 + $applied_classes = $classes;
434 522 }
435 523
436 524 if ( $has_style ) {
437 525 $style_applier = new Style_Applier( $this->create_css_converter( $variables_service ), $this->get_active_breakpoints() );
@@ -438,33 +526,116 @@
438 526 $style_result = $style_applier->apply( $index, [ $element_id => $style ], $style_apply_mode, $widget_configs );
439 527 if ( $style_result['error'] ) {
440 528 return $style_result['error'];
441 529 }
442 - $warnings = array_merge( $warnings, $style_result['warnings'] );
530 + $warnings = array_merge( $warnings, $style_result['warnings'] );
531 + $warning_codes = array_merge( $warning_codes, $style_result['warning_codes'] ?? [] );
532 + $variable_connections = $style_result['variable_connections'][ $element_id ] ?? [];
443 533 }
444 534
445 - if ( null !== $interactions ) {
446 - if ( ! is_array( $interactions ) ) {
447 - return new \WP_Error( 'invalid_input', __( 'interactions must be an array of interaction items.', 'elementor' ) );
535 + return [
536 + 'tree' => $tree,
537 + 'warnings' => $warnings,
538 + 'warning_codes' => $warning_codes,
539 + 'event_metadata' => [
540 + 'element_id' => $element_id,
541 + 'element_type' => (string) $element_type,
542 + 'applied_classes' => $applied_classes,
543 + 'variable_connections' => $variable_connections,
544 + 'interactions_events' => $interactions_events,
545 + ],
546 + ];
547 + }
548 +
549 + protected function build_interactions_events( string $element_type, array $previous_items, array $new_items ): array {
550 + if ( [] === $new_items ) {
551 + return [
552 + [
553 + 'event_name' => 'interactions_cleared',
554 + 'payload' => [
555 + 'affected_element_type' => $element_type,
556 + 'target_value' => count( $previous_items ),
557 + ],
558 + ],
559 + ];
560 + }
561 +
562 + $previous_by_id = [];
563 + foreach ( $previous_items as $item ) {
564 + if ( ! is_array( $item ) ) {
565 + continue;
448 566 }
449 - if ( ! Plugin::$instance->experiments->is_feature_active( Interactions_Module::EXPERIMENT_NAME ) ) {
450 - $warnings[] = __( 'Interactions experiment is not active. Interactions were not applied.', 'elementor' );
451 - } else {
452 - $interactions_applier = new Interactions_Applier( $this->get_plain_values_resolver() );
453 - $interactions_result = $interactions_applier->apply( $index, [ $element_id => $interactions ] );
454 - if ( $interactions_result['error'] ) {
455 - return $interactions_result['error'];
456 - }
457 - $warnings = array_merge( $warnings, $interactions_result['warnings'] );
567 +
568 + $id = $this->extract_interaction_id( $item );
569 + if ( null !== $id ) {
570 + $previous_by_id[ $id ] = true;
458 571 }
459 572 }
460 573
461 - return [
462 - 'tree' => $tree,
463 - 'warnings' => $warnings,
464 - ];
574 + $events = [];
575 +
576 + foreach ( $new_items as $item ) {
577 + if ( ! is_array( $item ) ) {
578 + continue;
579 + }
580 +
581 + $id = $item['interaction_id'] ?? '';
582 + $is_update = is_string( $id ) && '' !== $id && isset( $previous_by_id[ $id ] );
583 + $event_name = $is_update ? 'interaction_updated' : 'interaction_created';
584 +
585 + $events[] = [
586 + 'event_name' => $event_name,
587 + 'payload' => [
588 + 'affected_element_type' => $element_type,
589 + 'interaction_trigger' => $this->stringify_interaction_field( $item['trigger'] ?? '' ),
590 + 'interaction_effect' => $this->stringify_interaction_field( $item['animation'] ?? '' ),
591 + ],
592 + ];
593 + }
594 +
595 + return $events;
465 596 }
466 597
598 + private function extract_interaction_id( array $item ): ?string {
599 + if ( isset( $item['interaction_id'] ) && is_string( $item['interaction_id'] ) && '' !== $item['interaction_id'] ) {
600 + return $item['interaction_id'];
601 + }
602 +
603 + $nested = $item['value']['interaction_id']['value'] ?? $item['interaction_id']['value'] ?? null;
604 +
605 + if ( is_string( $nested ) && '' !== $nested ) {
606 + return $nested;
607 + }
608 +
609 + return null;
610 + }
611 +
612 + private function stringify_interaction_field( $value ): string {
613 + if ( is_string( $value ) ) {
614 + return $value;
615 + }
616 +
617 + if ( is_array( $value ) ) {
618 + if ( isset( $value['effect'] ) && is_string( $value['effect'] ) ) {
619 + return $value['effect'];
620 + }
621 +
622 + $nested_effect = $value['value']['effect']['value'] ?? $value['effect']['value'] ?? null;
623 + if ( is_string( $nested_effect ) && '' !== $nested_effect ) {
624 + return $nested_effect;
625 + }
626 +
627 + if ( isset( $value['value'] ) && ( is_string( $value['value'] ) || is_numeric( $value['value'] ) ) ) {
628 + return (string) $value['value'];
629 + }
630 + if ( isset( $value['$$type'] ) && is_string( $value['$$type'] ) ) {
631 + return (string) $value['$$type'];
632 + }
633 + }
634 +
635 + return '';
636 + }
637 +
467 638 private function resolve_document( int $post_id ) {
468 639 $document = Plugin::$instance->documents->get_doc_or_auto_save( $post_id, get_current_user_id() )
469 640 ?? Plugin::$instance->documents->get( $post_id );
470 641
@@ -559,6 +730,143 @@
559 730 }
560 731
561 732 private function get_active_breakpoints(): array {
562 733 return array_keys( Plugin::$instance->breakpoints->get_active_breakpoints() );
734 + }
735 +
736 + private function emit_update_events( array $pending_events, array $tree ): void {
737 + if ( empty( $pending_events ) ) {
738 + return;
739 + }
740 +
741 + $all_labels = $this->create_global_classes_repository()->all_labels();
742 + $class_counts = $this->count_class_usage_in_tree( $tree );
743 +
744 + foreach ( $pending_events as $meta ) {
745 + $action = $meta['action'] ?? '';
746 +
747 + if ( 'duplicate' === $action ) {
748 + foreach ( $meta['duplicated_element_types'] ?? [] as $element_name ) {
749 + Mcp_Event_Dispatcher::emit( 'element_added', [
750 + 'element_name' => $element_name,
751 + ] );
752 + }
753 + continue;
754 + }
755 +
756 + $element_type = $meta['element_type'] ?? '';
757 +
758 + foreach ( $meta['applied_classes'] ?? [] as $label ) {
759 + $class_id = array_search( $label, $all_labels, true );
760 +
761 + if ( false === $class_id ) {
762 + continue;
763 + }
764 +
765 + Mcp_Event_Dispatcher::emit( 'class_applied', [
766 + 'target_name' => 'apply_class',
767 + 'id' => (string) $class_id,
768 + 'name' => $label,
769 + 'affected_element_type' => $element_type,
770 + 'total_instances_after_apply' => $class_counts[ $class_id ] ?? 0,
771 + ] );
772 + }
773 +
774 + foreach ( $meta['variable_connections'] as $connection ) {
775 + Mcp_Event_Dispatcher::emit( 'variable_connected', [
776 + 'id' => $connection['variable_id'],
777 + 'var_type' => $connection['var_type'],
778 + 'control_path' => $connection['control_path'],
779 + ] );
780 + }
781 +
782 + foreach ( $meta['interactions_events'] ?? [] as $ie ) {
783 + Mcp_Event_Dispatcher::emit( $ie['event_name'], $ie['payload'] );
784 + }
785 + }
786 + }
787 +
788 + private function count_class_usage_in_tree( array $tree ): array {
789 + $counts = [];
790 + $this->walk_tree_for_class_counts( $tree, $counts );
791 + return $counts;
792 + }
793 +
794 + private function walk_tree_for_class_counts( array $elements, array &$counts ): void {
795 + foreach ( $elements as $element ) {
796 + $class_values = $element['settings']['classes']['value'] ?? [];
797 +
798 + if ( is_array( $class_values ) ) {
799 + foreach ( $class_values as $class_id ) {
800 + if ( is_string( $class_id ) && ! str_starts_with( $class_id, Style_Applier::LOCAL_STYLE_ID_PREFIX ) ) {
801 + $counts[ $class_id ] = ( $counts[ $class_id ] ?? 0 ) + 1;
802 + }
803 + }
804 + }
805 +
806 + if ( ! empty( $element['elements'] ) ) {
807 + $this->walk_tree_for_class_counts( $element['elements'], $counts );
808 + }
809 + }
810 + }
811 +
812 + private function emit_mcp_manage_elements_executed(
813 + int $started_at,
814 + int $post_id,
815 + ?Document $document = null,
816 + ?\WP_Error $top_level_error = null,
817 + array $operations = [],
818 + array $response = [],
819 + array $warning_codes = [],
820 + int $class_attachments = 0,
821 + int $interactions_count = 0
822 + ): void {
823 + $duration_ms = Tool_Performance_Metrics::duration_ms_since( $started_at );
824 +
825 + [ 'status' => $status, 'error_code' => $error_code ] = Tool_Performance_Metrics::resolve_status( $response, $top_level_error );
826 +
827 + $failed_results = array_filter( $response['results'] ?? [], fn( $r ) => 'error' === ( $r['status'] ?? '' ) );
828 + $failed_count = count( $failed_results );
829 + $failed_codes = array_values( array_unique( array_column( $failed_results, 'code' ) ) );
830 +
831 + $ops_count = count( $operations );
832 + $by_action = [];
833 + foreach ( $operations as $op ) {
834 + $action = $op['action'] ?? '';
835 + if ( is_string( $action ) && '' !== $action ) {
836 + $by_action[ $action ] = ( $by_action[ $action ] ?? 0 ) + 1;
837 + }
838 + }
839 +
840 + $dominant_action = '';
841 + if ( ! empty( $by_action ) ) {
842 + arsort( $by_action );
843 + $dominant_action = (string) array_key_first( $by_action );
844 + }
845 +
846 + $payload = [
847 + 'tool_name' => $this->get_ability_id(),
848 + 'status' => $status,
849 + 'duration_ms' => $duration_ms,
850 + 'post_id' => $post_id,
851 + 'action' => $dominant_action,
852 + 'operations_count' => $ops_count,
853 + 'operations_by_type' => $by_action,
854 + 'failed_operations_count' => $failed_count,
855 + 'failed_operation_codes' => $failed_codes,
856 + 'class_attachments_count' => $class_attachments,
857 + 'interactions_applied_count' => $interactions_count,
858 + 'warning_count' => count( array_unique( $warning_codes ) ),
859 + 'warning_types' => array_values( array_unique( $warning_codes ) ),
860 + ];
861 +
862 + if ( null !== $document ) {
863 + $payload['document_type'] = $document->get_name();
864 + }
865 +
866 + if ( null !== $error_code ) {
867 + $payload['error_code'] = $error_code;
868 + }
869 +
870 + Mcp_Event_Dispatcher::emit( 'mcp_manage_elements_executed', $payload );
563 871 }
564 872 }