PluginProbe
Elementor Website Builder – more than just a page builder / 4.3.0-beta3
Elementor Website Builder – more than just a page builder v4.3.0-beta3
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 4.1.0-beta1 4.1.0-dev1 All 452 releases
elementor / modules / mcp / abilities / manage-component-ability.php

manage-component-ability.php in Elementor Website Builder – more than just a page builder 4.3.0-beta3, at modules/mcp/abilities/manage-component-ability.php

779 lines 25.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Elementor\Modules\Mcp\Abilities;
4
5 use Elementor\Core\Base\Document;
6 use Elementor\Core\Utils\Collection;
7 use Elementor\Core\Utils\Document\Document_Mutator;
8 use Elementor\Modules\Components\Circular_Dependency_Validator;
9 use Elementor\Modules\Components\Components_Access_Controller;
10 use Elementor\Modules\Components\Components_Repository;
11 use Elementor\Modules\Components\Documents\Component as Component_Document;
12 use Elementor\Modules\Components\Non_Atomic_Widget_Validator;
13 use Elementor\Modules\Components\Save_Components_Validator;
14 use Elementor\Modules\Mcp\Abilities\Utils\Composition_Compiler;
15 use Elementor\Modules\Mcp\Abilities\Utils\Insufficient_Permissions_Error;
16 use Elementor\Modules\Mcp\Abilities\Utils\Overridable_Props_Builder;
17 use Elementor\Modules\Mcp\Abilities\Utils\Prompt_Loader;
18 use Elementor\Modules\Mcp\Events\Mcp_Event_Dispatcher;
19 use Elementor\Plugin;
20
21 if ( ! defined( 'ABSPATH' ) ) {
22 exit;
23 }
24
25 class Manage_Component_Ability extends Abstract_Ability {
26
27 const ACTION_CREATE = 'create';
28 const ACTION_UPDATE = 'update';
29 const ACTION_RENAME = 'rename';
30 const ACTION_ARCHIVE = 'archive';
31 const ACTION_PUBLISH = 'publish';
32
33 private ?Components_Repository $repository;
34
35 public function __construct( ?Components_Repository $repository = null ) {
36 $this->repository = $repository;
37 }
38
39 protected function get_ability_id(): string {
40 return 'elementor/manage-component';
41 }
42
43 protected function get_definition(): Ability_Definition {
44 return new Ability_Definition(
45 __( 'Manage Elementor Component', 'elementor' ),
46 Prompt_Loader::load( 'manage-component' ),
47 'elementor',
48 $this->get_output_schema(),
49 [
50 'annotations' => [
51 'readonly' => false,
52 'idempotent' => false,
53 'destructive' => true,
54 ],
55 ],
56 fn() => current_user_can( 'manage_options' ),
57 $this->get_input_schema()
58 );
59 }
60
61 public function execute( $input = [] ) {
62 $input = is_array( $input ) ? $input : [];
63
64 switch ( $input['action'] ?? null ) {
65 case self::ACTION_CREATE:
66 return $this->handle_create( $input );
67 case self::ACTION_UPDATE:
68 return $this->handle_update( $input );
69 case self::ACTION_RENAME:
70 return $this->handle_rename( $input );
71 case self::ACTION_ARCHIVE:
72 return $this->handle_archive( $input );
73 case self::ACTION_PUBLISH:
74 return $this->handle_publish( $input );
75 }
76
77 return $this->invalid_input( __( 'action must be one of: create, update, rename, archive, publish.', 'elementor' ) );
78 }
79
80 private function handle_create( array $input ) {
81 $gate = $this->check_access( self::ACTION_CREATE, fn() => Components_Access_Controller::can_create() );
82 if ( $gate ) {
83 return $gate;
84 }
85
86 $title = isset( $input['title'] ) ? trim( (string) $input['title'] ) : '';
87 if ( strlen( $title ) < 2 ) {
88 return $this->invalid_input( __( 'create requires a title of at least 2 characters.', 'elementor' ) );
89 }
90
91 $source_result = $this->resolve_create_elements( $input );
92 if ( is_wp_error( $source_result ) ) {
93 return $source_result;
94 }
95 [ 'elements' => $elements, 'warnings' => $warnings ] = $source_result;
96 $source_id_map = $source_result['source_id_map'] ?? [];
97
98 $settings = [];
99 $overridable_error = $this->apply_overridable_props( $elements, $input, $settings, $source_id_map );
100 if ( is_wp_error( $overridable_error ) ) {
101 return $overridable_error;
102 }
103
104 $uid = $this->generate_uid();
105 $item = [
106 'uid' => $uid,
107 'title' => $title,
108 'elements' => $elements,
109 'settings' => $settings,
110 ];
111 $items = Collection::make( [ $item ] );
112
113 $save_validation = Save_Components_Validator::make( $this->get_repository()->all() )->validate( $items );
114 if ( ! $save_validation['success'] ) {
115 return new \WP_Error(
116 'components_validation_failed',
117 __( 'Validation failed: ', 'elementor' ) . implode( ' ', $save_validation['messages'] ),
118 [ 'status' => \WP_Http::UNPROCESSABLE_ENTITY ]
119 );
120 }
121
122 $circular_error = $this->validate_circular_dependency(
123 fn() => Circular_Dependency_Validator::make()->validate_new_components( $items )
124 );
125 if ( $circular_error ) {
126 return $circular_error;
127 }
128
129 $non_atomic_error = $this->validate_atomic_elements( $elements );
130 if ( $non_atomic_error ) {
131 return $non_atomic_error;
132 }
133
134 try {
135 $component_id = $this->get_repository()->create( $title, $elements, $this->resolve_status( $input ), $uid, $settings );
136 } catch ( \Exception $e ) {
137 return new \WP_Error( 'create_failed', $e->getMessage(), [ 'status' => \WP_Http::UNPROCESSABLE_ENTITY ] );
138 }
139
140 $component = $this->get_repository()->get( $component_id, false );
141
142 $this->emit_component_created_event( $title, $component_id, $elements );
143
144 $response = [
145 'success' => true,
146 'component_id' => $component_id,
147 'uid' => $uid,
148 ] + $this->document_links( $component );
149
150 if ( ! empty( $warnings ) ) {
151 $response['warnings'] = $warnings;
152 }
153
154 return $response;
155 }
156
157 private function handle_update( array $input ) {
158 $gate = $this->check_access( self::ACTION_UPDATE, fn() => Components_Access_Controller::can_edit() );
159 if ( $gate ) {
160 return $gate;
161 }
162
163 $component_id = isset( $input['component_id'] ) ? (int) $input['component_id'] : 0;
164 if ( $component_id <= 0 ) {
165 return $this->invalid_input( __( 'update requires component_id.', 'elementor' ) );
166 }
167
168 $component = $this->get_repository()->get_for_edit( $component_id, $this->resolve_status( $input ) );
169 if ( ! $component ) {
170 return $this->not_found( __( 'Component not found.', 'elementor' ) );
171 }
172
173 $has_xml = ! empty( $input['xml_structure'] ) && is_string( $input['xml_structure'] );
174
175 return $has_xml
176 ? $this->update_with_xml( $component, $input )
177 : $this->update_overridable_props_only( $component, $input );
178 }
179
180 private function handle_rename( array $input ) {
181 $gate = $this->check_access( self::ACTION_RENAME, fn() => Components_Access_Controller::can_rename() );
182 if ( $gate ) {
183 return $gate;
184 }
185
186 $component_id = isset( $input['component_id'] ) ? (int) $input['component_id'] : 0;
187 $title = isset( $input['title'] ) ? trim( (string) $input['title'] ) : '';
188
189 if ( $component_id <= 0 || strlen( $title ) < 2 ) {
190 return $this->invalid_input( __( 'rename requires component_id and a title of at least 2 characters.', 'elementor' ) );
191 }
192
193 $success = $this->get_repository()->update_title( $component_id, $title, $this->resolve_status( $input ) );
194 if ( ! $success ) {
195 return $this->not_found( __( 'Component not found or title could not be updated.', 'elementor' ) );
196 }
197
198 return [
199 'success' => true,
200 'component_id' => $component_id,
201 ];
202 }
203
204 private function handle_archive( array $input ) {
205 $gate = $this->check_access( self::ACTION_ARCHIVE, fn() => Components_Access_Controller::can_delete() );
206 if ( $gate ) {
207 return $gate;
208 }
209
210 $component_ids = $this->normalize_ids( $input['component_ids'] ?? [] );
211 if ( empty( $component_ids ) ) {
212 return $this->invalid_input( __( 'archive requires a non-empty component_ids array of positive integers.', 'elementor' ) );
213 }
214
215 $result = $this->get_repository()->archive( $component_ids, $this->resolve_status( $input ) );
216
217 return [
218 'success' => empty( $result['failedIds'] ),
219 'success_ids' => $result['successIds'],
220 'failed_ids' => $result['failedIds'],
221 ];
222 }
223
224 private function handle_publish( array $input ) {
225 $gate = $this->check_access( self::ACTION_PUBLISH, fn() => Components_Access_Controller::can_publish() );
226 if ( $gate ) {
227 return $gate;
228 }
229
230 $component_id = isset( $input['component_id'] ) ? (int) $input['component_id'] : 0;
231 if ( $component_id <= 0 ) {
232 return $this->invalid_input( __( 'publish requires component_id.', 'elementor' ) );
233 }
234
235 $component = $this->get_repository()->get( $component_id );
236 if ( ! $component ) {
237 return $this->not_found( __( 'Component not found.', 'elementor' ) );
238 }
239
240 if ( ! $this->get_repository()->publish_component( $component ) ) {
241 return new \WP_Error(
242 'publish_failed',
243 __( 'Failed to publish component.', 'elementor' ),
244 [ 'status' => \WP_Http::INTERNAL_SERVER_ERROR ]
245 );
246 }
247
248 $published = $this->get_repository()->get( $component->get_main_id(), false );
249
250 return [
251 'success' => true,
252 'component_id' => $component->get_main_id(),
253 ] + $this->document_links( $published );
254 }
255
256 private function update_overridable_props_only( Component_Document $component, array $input ) {
257 if ( ! is_array( $input['overridable_props'] ?? null ) || empty( $input['overridable_props'] ) ) {
258 return $this->invalid_input( __( 'update without xml_structure requires non-empty overridable_props.', 'elementor' ) );
259 }
260
261 $elements = $component->get_elements_data();
262 $settings = [];
263
264 $overridable_error = $this->apply_overridable_props( $elements, $input, $settings );
265 if ( is_wp_error( $overridable_error ) ) {
266 return $overridable_error;
267 }
268
269 return $this->save_component( $component, $elements, $settings );
270 }
271
272 private function update_with_xml( Component_Document $component, array $input ) {
273 $compiled = $this->compile_composition( $input, $component );
274 if ( is_wp_error( $compiled ) ) {
275 return $compiled;
276 }
277
278 $elements = $this->assign_element_ids( $compiled['elements'] );
279 $warnings = $compiled['warnings'];
280
281 $non_atomic_error = $this->validate_atomic_elements( $elements );
282 if ( $non_atomic_error ) {
283 return $non_atomic_error;
284 }
285
286 $settings = [];
287 $overridable_error = $this->apply_overridable_props( $elements, $input, $settings );
288 if ( is_wp_error( $overridable_error ) ) {
289 return $overridable_error;
290 }
291
292 $circular_error = $this->validate_circular_dependency(
293 fn() => Circular_Dependency_Validator::make()->validate( $component->get_main_id(), $elements )
294 );
295 if ( $circular_error ) {
296 return $circular_error;
297 }
298
299 $result = $this->save_component( $component, $elements, $settings );
300 if ( is_wp_error( $result ) || empty( $warnings ) ) {
301 return $result;
302 }
303
304 return $result + [ 'warnings' => $warnings ];
305 }
306
307 private function save_component( Component_Document $component, array $elements, array $settings ) {
308 try {
309 $saved = $component->save( [
310 'elements' => $elements,
311 'settings' => $settings,
312 ] );
313 } catch ( \Exception $e ) {
314 if ( str_contains( $e->getMessage(), 'components that contain each other' ) ) {
315 return new \WP_Error(
316 'circular_dependency_detected',
317 __( "Can't add this component - components that contain each other can't be nested.", 'elementor' ),
318 [ 'status' => \WP_Http::UNPROCESSABLE_ENTITY ]
319 );
320 }
321
322 return new \WP_Error( 'save_failed', $e->getMessage(), [ 'status' => \WP_Http::UNPROCESSABLE_ENTITY ] );
323 }
324
325 if ( ! $saved ) {
326 return new \WP_Error( 'save_failed', __( 'Failed to save component.', 'elementor' ), [ 'status' => \WP_Http::INTERNAL_SERVER_ERROR ] );
327 }
328
329 return [
330 'success' => true,
331 'component_id' => $component->get_main_id(),
332 ] + $this->document_links( $component );
333 }
334
335 private function validate_circular_dependency( callable $validate ): ?\WP_Error {
336 $circular_validation = $validate();
337
338 if ( $circular_validation['success'] ) {
339 return null;
340 }
341
342 return new \WP_Error(
343 'circular_dependency_detected',
344 __( "Can't add this component - components that contain each other can't be nested.", 'elementor' ),
345 [
346 'status' => \WP_Http::UNPROCESSABLE_ENTITY,
347 'caused_by' => $circular_validation['messages'],
348 ]
349 );
350 }
351
352 private function validate_atomic_elements( array $elements ): ?\WP_Error {
353 $validation = Non_Atomic_Widget_Validator::make()->validate( $elements );
354
355 if ( $validation['success'] ) {
356 return null;
357 }
358
359 return new \WP_Error(
360 Non_Atomic_Widget_Validator::ERROR_CODE,
361 __( 'Components require atomic elements only. Remove widgets to create this component.', 'elementor' ),
362 [
363 'status' => \WP_Http::UNPROCESSABLE_ENTITY,
364 'non_atomic_elements' => $validation['non_atomic_elements'],
365 ]
366 );
367 }
368
369 /**
370 * @return array{elements: array[], warnings: string[], source_id_map?: array<string,string>}|\WP_Error
371 */
372 private function resolve_create_elements( array $input ) {
373 $has_xml = ! empty( $input['xml_structure'] ) && is_string( $input['xml_structure'] );
374 $has_source_element = ! empty( $input['source_post_id'] ) && ! empty( $input['element_id'] );
375
376 if ( $has_xml && $has_source_element ) {
377 return $this->invalid_input( __( 'create accepts either xml_structure or source_post_id/element_id, not both.', 'elementor' ) );
378 }
379
380 if ( $has_xml ) {
381 return $this->compile_elements_from_xml( $input );
382 }
383
384 if ( $has_source_element ) {
385 return $this->copy_elements_from_source( $input );
386 }
387
388 return [
389 'elements' => [],
390 'warnings' => [],
391 ];
392 }
393
394 /**
395 * @return array{elements: array[], warnings: string[]}|\WP_Error
396 */
397 private function compile_elements_from_xml( array $input ) {
398 $compiled = $this->compile_composition( $input );
399
400 if ( is_wp_error( $compiled ) ) {
401 return $compiled;
402 }
403
404 return [
405 'elements' => $this->assign_element_ids( $compiled['elements'] ),
406 'warnings' => $compiled['warnings'],
407 ];
408 }
409
410 /**
411 * @return array{elements: array[], warnings: string[], dom: \DOMDocument, xml_parser: \Elementor\Modules\Mcp\Abilities\Build_Composition\Xml_Parser}|\WP_Error
412 */
413 private function compile_composition( array $input, ?Document $document = null ) {
414 $compiled = Composition_Compiler::make()->compile(
415 $input,
416 $document,
417 [],
418 Composition_Compiler::COMPONENT_PARENT_ID
419 );
420 if ( is_wp_error( $compiled ) ) {
421 return $compiled;
422 }
423
424 if ( 1 !== count( $compiled['elements'] ) ) {
425 return new \WP_Error(
426 'component_requires_single_root',
427 __( 'A component composition must contain exactly one root element.', 'elementor' ),
428 [ 'status' => \WP_Http::UNPROCESSABLE_ENTITY ]
429 );
430 }
431
432 return $compiled;
433 }
434
435 /**
436 * @return array{elements: array[], warnings: string[], source_id_map: array<string,string>}|\WP_Error
437 */
438 private function copy_elements_from_source( array $input ) {
439 $source_post_id = (int) $input['source_post_id'];
440 $element_id = (string) $input['element_id'];
441
442 if ( ! current_user_can( 'edit_post', $source_post_id ) ) {
443 return new \WP_Error(
444 'elementor_forbidden',
445 __( 'You do not have permission to read source_post_id.', 'elementor' ),
446 [ 'status' => \WP_Http::FORBIDDEN ]
447 );
448 }
449
450 $document = Plugin::$instance->documents->get_doc_or_auto_save( $source_post_id, get_current_user_id() )
451 ?? Plugin::$instance->documents->get( $source_post_id );
452
453 if ( ! $document ) {
454 return $this->not_found( __( 'source_post_id not found.', 'elementor' ) );
455 }
456
457 $found = Document_Mutator::instance()->find_by_id( $document->get_elements_data(), $element_id );
458 if ( null === $found ) {
459 return $this->not_found( __( 'element_id was not found on source_post_id.', 'elementor' ) );
460 }
461
462 $source_id_map = [];
463 $elements = $this->assign_element_ids_recording_source_ids( [ $found ], $source_id_map );
464
465 return [
466 'elements' => $elements,
467 'warnings' => [],
468 'source_id_map' => $source_id_map,
469 ];
470 }
471
472 /**
473 * Builds the native overridable-props payload and stamps `overridable` envelopes
474 * onto the referenced element settings. Validation and persistence of the native
475 * payload itself is left to the component document's save hook, which already
476 * parses and persists it for every component save.
477 *
478 * @param array[] $elements
479 * @param array $input
480 * @param array $settings
481 * @param array<string,string> $source_id_map old-source-id => new-machine-id, when the tree
482 * came from `copy_elements_from_source`. Used to
483 * let callers address targets by the ids they
484 * saw on the source document, before id regeneration.
485 *
486 * @return \WP_Error|null
487 */
488 private function apply_overridable_props( array &$elements, array $input, array &$settings, array $source_id_map = [] ) {
489 if ( ! is_array( $input['overridable_props'] ?? null ) || empty( $input['overridable_props'] ) ) {
490 return null;
491 }
492
493 $definitions = $this->remap_overridable_targets( $input['overridable_props'], $source_id_map );
494
495 $builder_result = Overridable_Props_Builder::make( $this->get_repository() )->build( $elements, $definitions );
496 if ( is_wp_error( $builder_result ) ) {
497 return $builder_result;
498 }
499
500 $settings['overridable_props'] = $builder_result;
501
502 return null;
503 }
504
505 /**
506 * Rewrites `overridable_props[*].target` from source-document ids to the freshly assigned
507 * ids, so callers can keep referencing the elements by the ids they observed on the source.
508 * Unknown targets pass through untouched so the xml_structure `configuration-id` path (which
509 * resolves via `editor_settings.title` in `Overridable_Props_Builder::find_element_ref`) and
510 * regular id targets still work.
511 *
512 * @param array $definitions
513 * @param array<string,string> $source_id_map
514 */
515 private function remap_overridable_targets( array $definitions, array $source_id_map ): array {
516 if ( empty( $source_id_map ) ) {
517 return $definitions;
518 }
519
520 foreach ( $definitions as $override_key => &$definition ) {
521 if ( ! is_array( $definition ) ) {
522 continue;
523 }
524 $target = $definition['target'] ?? null;
525 if ( is_string( $target ) && isset( $source_id_map[ $target ] ) ) {
526 $definition['target'] = $source_id_map[ $target ];
527 }
528 }
529
530 return $definitions;
531 }
532
533 private function assign_element_ids( array $elements ): array {
534 return array_map( fn( array $element ) => $this->assign_element_id( $element ), $elements );
535 }
536
537 private function assign_element_id( array $element ): array {
538 $element['id'] = Document_Mutator::instance()->generate_id();
539
540 if ( ! empty( $element['elements'] ) && is_array( $element['elements'] ) ) {
541 $element['elements'] = array_map( fn( array $child ) => $this->assign_element_id( $child ), $element['elements'] );
542 }
543
544 return $element;
545 }
546
547 /**
548 * Regenerates ids like `assign_element_ids`, but records the mapping from each element's
549 * old id to its new id so callers can keep addressing the tree by pre-regeneration ids.
550 *
551 * @param array[] $elements
552 * @param array<string,string> $source_id_map Populated by reference.
553 */
554 private function assign_element_ids_recording_source_ids( array $elements, array &$source_id_map ): array {
555 $result = [];
556 foreach ( $elements as $element ) {
557 $result[] = $this->assign_element_id_recording_source_id( $element, $source_id_map );
558 }
559 return $result;
560 }
561
562 private function assign_element_id_recording_source_id( array $element, array &$source_id_map ): array {
563 $old_id = isset( $element['id'] ) ? (string) $element['id'] : '';
564
565 $element['id'] = Document_Mutator::instance()->generate_id();
566
567 if ( '' !== $old_id ) {
568 $source_id_map[ $old_id ] = $element['id'];
569 }
570
571 if ( ! empty( $element['elements'] ) && is_array( $element['elements'] ) ) {
572 $element['elements'] = $this->assign_element_ids_recording_source_ids( $element['elements'], $source_id_map );
573 }
574
575 return $element;
576 }
577
578 private function document_links( Component_Document $component ): array {
579 $editor_url = $component->get_edit_url();
580
581 return [
582 'edit_url' => $editor_url,
583 'llm_instructions' => sprintf(
584 /* translators: %s: Component editor URL. */
585 __( 'You MUST show the user this link to review the component: %s', 'elementor' ),
586 $editor_url
587 ),
588 ];
589 }
590
591 private function resolve_status( array $input ): string {
592 return 'draft' === ( $input['publish_status'] ?? 'publish' ) ? Document::STATUS_DRAFT : Document::STATUS_PUBLISH;
593 }
594
595 private function generate_uid(): string {
596 return 'component-' . wp_generate_uuid4();
597 }
598
599 private function normalize_ids( $raw ): array {
600 if ( ! is_array( $raw ) ) {
601 return [];
602 }
603
604 $ids = [];
605 foreach ( $raw as $value ) {
606 $id = (int) $value;
607 if ( $id > 0 ) {
608 $ids[ $id ] = $id;
609 }
610 }
611
612 return array_values( $ids );
613 }
614
615 private function check_access( string $action, callable $can ): ?\WP_Error {
616 return $can() ? null : Insufficient_Permissions_Error::for_action( $action );
617 }
618
619 private function invalid_input( string $message ): \WP_Error {
620 return new \WP_Error( 'invalid_input', $message, [ 'status' => \WP_Http::BAD_REQUEST ] );
621 }
622
623 private function not_found( string $message ): \WP_Error {
624 return new \WP_Error( 'elementor_not_found', $message, [ 'status' => \WP_Http::NOT_FOUND ] );
625 }
626
627 private function get_repository(): Components_Repository {
628 if ( ! $this->repository ) {
629 $this->repository = new Components_Repository();
630 }
631
632 return $this->repository;
633 }
634
635 private function get_output_schema(): array {
636 return [
637 'type' => 'object',
638 'properties' => [
639 'success' => [ 'type' => 'boolean' ],
640 'component_id' => [ 'type' => 'integer' ],
641 'uid' => [
642 'type' => 'string',
643 'description' => 'Server-generated component uid, only present for create.',
644 ],
645 'success_ids' => [
646 'type' => 'array',
647 'items' => [ 'type' => 'integer' ],
648 'description' => 'archive only.',
649 ],
650 'failed_ids' => [
651 'type' => 'array',
652 'items' => [ 'type' => 'integer' ],
653 'description' => 'archive only.',
654 ],
655 'edit_url' => [
656 'type' => 'string',
657 'description' => 'Component documents have no public permalink; this is the editor URL to review the change.',
658 ],
659 'llm_instructions' => [
660 'type' => 'string',
661 'description' => 'Mandatory next step: include this text (with the editor link) in your reply to the user.',
662 ],
663 'warnings' => [
664 'type' => 'array',
665 'items' => [ 'type' => 'string' ],
666 'description' => 'Non-fatal notices from XML compilation, e.g. props skipped or CSS that fell back to custom_css.',
667 ],
668 ],
669 ];
670 }
671
672 private function get_input_schema(): array {
673 return [
674 'type' => 'object',
675 'required' => [ 'action' ],
676 'properties' => [
677 'action' => [
678 'type' => 'string',
679 'enum' => [ self::ACTION_CREATE, self::ACTION_UPDATE, self::ACTION_RENAME, self::ACTION_ARCHIVE, self::ACTION_PUBLISH ],
680 ],
681 'title' => [
682 'type' => 'string',
683 'minLength' => 2,
684 'maxLength' => 200,
685 'description' => 'create: component title. rename: the new title.',
686 ],
687 'source_post_id' => [
688 'type' => 'integer',
689 'description' => 'create: WordPress post id to copy an existing element subtree from. Requires element_id. Mutually exclusive with xml_structure.',
690 ],
691 'element_id' => [
692 'type' => 'string',
693 'description' => 'create: id of the element (within source_post_id, from elementor/get-page-structure) to copy as the component root.',
694 ],
695 'xml_structure' => [
696 'type' => 'string',
697 'description' => 'create/update: same tag language as elementor/build-composition, with exactly one root element. Mutually exclusive with source_post_id/element_id.',
698 ],
699 'element_config' => [
700 'type' => 'object',
701 'default' => (object) [],
702 'description' => 'Same shape as elementor/build-composition element_config. Only used with xml_structure.',
703 ],
704 'classes' => [
705 'type' => 'object',
706 'default' => (object) [],
707 'description' => 'Same shape as elementor/build-composition classes. Only used with xml_structure.',
708 ],
709 'style' => [
710 'type' => 'object',
711 'default' => (object) [],
712 'description' => 'Same shape as elementor/build-composition style. Only used with xml_structure.',
713 ],
714 'interactions' => [
715 'type' => 'object',
716 'default' => (object) [],
717 'description' => 'Same shape as elementor/build-composition interactions. Only used with xml_structure.',
718 ],
719 'overridable_props' => [
720 'type' => 'object',
721 'description' => 'Caller-supplied record mapping override-key → { target, prop_key, label, group? }. target identifies the element to expose: for xml_structure, use the same configuration-id you set on that element; for source_post_id/element_id (create) or an update without xml_structure, use the real element id (from elementor/get-page-structure). Group IDs and origin values are generated server-side.',
722 ],
723 'publish_status' => [
724 'type' => 'string',
725 'enum' => [ 'publish', 'draft' ],
726 'default' => 'publish',
727 'description' => 'create: initial document status. update/rename/archive: "draft" edits an autosave copy instead of the live document; the change is not visible on pages using this component until you publish it.',
728 ],
729 'component_id' => [
730 'type' => 'integer',
731 'description' => 'update/rename/publish target component id.',
732 ],
733 'component_ids' => [
734 'type' => 'array',
735 'items' => [ 'type' => 'integer' ],
736 'description' => 'archive targets.',
737 ],
738 ],
739 ];
740 }
741
742 private function emit_component_created_event( string $title, int $component_id, array $elements ): void {
743 [ 'elements_count' => $nested_elements_count, 'components_count' => $nested_components_count ] = $this->count_nested_elements( $elements );
744 $top_element_type = $elements[0]['elType'] ?? ( $elements[0]['widgetType'] ?? '' );
745
746 Mcp_Event_Dispatcher::emit( 'component_created', [
747 'id' => (string) $component_id,
748 'name' => $title,
749 'nested_elements_count' => $nested_elements_count,
750 'nested_components_count' => $nested_components_count,
751 'top_element_type' => $top_element_type,
752 ] );
753 }
754
755 private function count_nested_elements( array $elements ): array {
756 $elements_count = count( $elements );
757 $components_count = 0;
758
759 foreach ( $elements as $element ) {
760 if ( 'e-component' === ( $element['widgetType'] ?? '' ) ) {
761 $components_count++;
762 }
763
764 $children = $element['elements'] ?? [];
765
766 if ( ! empty( $children ) ) {
767 $child_counts = $this->count_nested_elements( $children );
768 $elements_count += $child_counts['elements_count'];
769 $components_count += $child_counts['components_count'];
770 }
771 }
772
773 return [
774 'elements_count' => $elements_count,
775 'components_count' => $components_count,
776 ];
777 }
778 }
779